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..42f3976 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,23 @@ your own 32-byte base64url key. Verified browser-facing defaults are: -- Lock Server: -- creator demo: -- reader demo: +- Lock Server: +- creator demo: +- reader demo: + +For the opt-in payment-lock demonstration, including Paykit Server, Bitcoin regtest, +and Fulcrum, use the separate Compose definition: + +```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 @@ -425,7 +439,8 @@ Example: "params": { "recipient_pubky": "pubky", "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 } } ], @@ -458,7 +473,7 @@ For example: A submitted proof bundle is sent by the viewer before verification. It is not stored as an entitlement unless verification succeeds. -For `paykit-payment`, the content lock criterion params are exactly `recipient_pubky`, positive base-unit string `amount`, and non-empty `asset`. `recipient_pubky` must equal the content-lock creator. In v1 it must be the lock's only criterion, referenced exactly once by the lock logic. The submitted proof carries no payment details in its proof payload; it uses top-level `reader_public_key` plus the canonical `pubky_lock_resource` so the Lock Server can create the Paykit invoice. +For `paykit-payment`, the content lock criterion params are exactly `recipient_pubky`, positive base-unit string `amount`, non-empty `asset`, and positive whole-hour JSON `u64` `payment_in`. `recipient_pubky` must equal the content-lock creator. In v1 it must be the lock's only criterion, referenced exactly once by the lock logic. The submitted proof carries no payment details in its proof payload; it uses top-level `reader_public_key` plus the canonical `pubky_lock_resource` so the Lock Server can create the Paykit invoice. Example: diff --git a/compose.paykit-local-demo.yaml b/compose.paykit-local-demo.yaml new file mode 100644 index 0000000..97391ae --- /dev/null +++ b/compose.paykit-local-demo.yaml @@ -0,0 +1,433 @@ +# Local development and demonstration only; this is not a production deployment definition. +name: pubky-locks-paykit-demo + +services: + compose-bootstrap: + image: node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 + user: "0:0" + working_dir: /workspace + command: + - /bin/sh + - -euc + - | + node examples/js-sdk/scripts/init-paykit-compose.mjs + chown -R 1000:1000 \ + .local/compose-secrets.json \ + .local/js-sdk-demo \ + .local/demo-config \ + .local/creator-public \ + .local/bitcoin-bootstrap \ + .local/content-creator \ + .local/content-viewer \ + .local/paykit-reader \ + .local/locks-postgres \ + .local/paykit-postgres \ + .local/bitcoin-rpc \ + .local/pubky-homeserver \ + .local/locks-server \ + .local/paykit-server \ + .local/paykit-config \ + .local/homegate-bridge + chmod 0600 .local/paykit-server/paykit.env + volumes: + - ./examples/js-sdk:/workspace/examples/js-sdk:ro + - ./.local:/workspace/.local + + postgres: + image: postgres:17-bookworm@sha256:4f736ae292687621d4dbe0d499ffd024a36bd2ee7d8ca6f2ccd4c800f047b394 + entrypoint: ["/bin/bash", "-euc"] + command: + - | + set -a + . /run/compose-local/locks-postgres.env + set +a + exec /usr/local/bin/docker-entrypoint.sh postgres + volumes: + - ./docker/postgres-init/01-create-pubky-homeserver.sql:/docker-entrypoint-initdb.d/01-create-pubky-homeserver.sql:ro + - ./.local/locks-postgres:/run/compose-local:ro + - locks-postgres-data:/var/lib/postgresql/data + depends_on: + compose-bootstrap: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "pg_isready -U locks -d locks_test"] + interval: 2s + timeout: 5s + retries: 30 + + paykit-postgres: + image: postgres:17-bookworm@sha256:4f736ae292687621d4dbe0d499ffd024a36bd2ee7d8ca6f2ccd4c800f047b394 + entrypoint: ["/bin/bash", "-euc"] + command: + - | + set -a + . /run/compose-local/paykit-server/postgres.env + set +a + exec /usr/local/bin/docker-entrypoint.sh postgres + volumes: + - ./.local/paykit-postgres:/run/compose-local/paykit-server:ro + - paykit-postgres-data:/var/lib/postgresql/data + depends_on: + compose-bootstrap: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "pg_isready -U paykit -d paykit"] + interval: 2s + timeout: 5s + retries: 30 + + bitcoin: + image: bitcoin/bitcoin:29.1@sha256:de62c536feb629bed65395f63afd02e3a7a777a3ec82fbed773d50336a739319 + volumes: + - ./.local/bitcoin-rpc:/run/compose-local:ro + - bitcoin-data:/home/bitcoin/.bitcoin + entrypoint: ["/bin/bash", "-euc"] + command: + - | + set -a + . /run/compose-local/bitcoin-rpc.env + set +a + umask 077 + mkdir -p "$${BITCOIN_DATA}" + printf 'rpcuser=%s\nrpcpassword=%s\n' "$${BITCOIN_RPC_USER}" "$${BITCOIN_RPC_PASSWORD}" > "$${BITCOIN_DATA}/bitcoin.conf" + exec /entrypoint.sh bitcoind \ + -conf="$${BITCOIN_DATA}/bitcoin.conf" \ + -regtest=1 \ + -server=1 \ + -txindex=1 \ + -fallbackfee=0.00001 \ + -rpcbind=0.0.0.0 \ + -rpcallowip=0.0.0.0/0 \ + -rpcport=18443 + depends_on: + compose-bootstrap: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "bitcoin-cli -conf=\"$${BITCOIN_DATA}/bitcoin.conf\" -regtest getblockchaininfo >/dev/null"] + interval: 2s + timeout: 5s + retries: 60 + + bitcoin-bootstrap: + image: bitcoin/bitcoin:29.1@sha256:de62c536feb629bed65395f63afd02e3a7a777a3ec82fbed773d50336a739319 + user: "1000:1000" + network_mode: service:bitcoin + entrypoint: ["/bin/bash", "-euc"] + command: + - | + set -a + . /run/compose-local/bitcoin-rpc.env + set +a + exec /usr/local/bin/bitcoin-bootstrap.sh + volumes: + - ./.local/bitcoin-bootstrap:/home/bitcoin/.bitcoin + - ./.local/bitcoin-rpc:/run/compose-local:ro + - ./docker/bitcoin-bootstrap.sh:/usr/local/bin/bitcoin-bootstrap.sh:ro + depends_on: + bitcoin: + condition: service_healthy + + fulcrum: + image: cculianu/fulcrum:v1.11.1@sha256:70f06b93ab5863997992d4b4508312fe81ce576017e16ecc7e69c7d38165bdf2 + entrypoint: ["/bin/sh", "-euc"] + command: + - | + set -a + . /run/compose-local/bitcoin-rpc.env + set +a + exec /entrypoint.sh Fulcrum -b bitcoin:18443 -t 0.0.0.0:50001 + volumes: + - ./.local/bitcoin-rpc:/run/compose-local:ro + - fulcrum-data:/data + depends_on: + bitcoin-bootstrap: + condition: service_completed_successfully + ports: + - "127.0.0.1:${LOCKS_ELECTRUM_PORT:-60001}:50001" + + electrum-readiness: + image: node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 + user: "1000:1000" + command: ["node", "/usr/local/bin/electrum-readiness.mjs"] + volumes: + - ./examples/js-sdk/scripts/electrum-readiness.mjs:/usr/local/bin/electrum-readiness.mjs:ro + depends_on: + fulcrum: + condition: service_started + + pubky-testnet: + build: + context: . + dockerfile: docker/pubky-testnet.Dockerfile + args: + PUBKY_CORE_REV: 75eb1324f86e8caa16c41f18a2cd6b8e1909ee7b + ports: + - "127.0.0.1:${LOCKS_DHT_PORT:-6881}:6881/udp" + - "127.0.0.1:${LOCKS_DHT_PORT:-6881}:6881/tcp" + - "127.0.0.1:${LOCKS_PKARR_RELAY_PORT:-15411}:15411" + - "127.0.0.1:${LOCKS_HTTP_RELAY_PORT:-15412}:15412" + - "127.0.0.1:${LOCKS_HOMESERVER_HTTP_PORT:-6286}:6286" + - "127.0.0.1:${LOCKS_HOMESERVER_PUBKY_PORT:-6287}:6287" + - "127.0.0.1:${LOCKS_SERVER_PORT:-3000}:3000" + - "127.0.0.1:${LOCKS_PAYKIT_PORT:-3001}:3001" + - "127.0.0.1:${LOCKS_CREATOR_DEMO_PORT:-8080}:8080" + - "127.0.0.1:${LOCKS_READER_DEMO_PORT:-8088}: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 + + homegate-bridge: + image: node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 + user: "1000:1000" + working_dir: /workspace + command: + - /bin/sh + - -euc + - | + set -a + . /run/compose-local/homegate-bridge/homegate.env + set +a + exec node examples/js-sdk/scripts/homegate-bridge.mjs + environment: + HOMEGATE_BRIDGE_CONFIG: /run/compose-local/demo-config/config.json + HOMEGATE_BRIDGE_HOMESERVER_ADMIN_URL: http://pubky-testnet:6288 + ports: + - "127.0.0.1:${LOCKS_HOMEGATE_PORT:-6288}:8082" + volumes: + - ./examples/js-sdk:/workspace/examples/js-sdk:ro + - ./.local/demo-config:/run/compose-local/demo-config:ro + - ./.local/homegate-bridge:/run/compose-local/homegate-bridge:ro + depends_on: + pubky-testnet: + condition: service_started + demo-config: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8082/health').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1))"] + interval: 2s + timeout: 2s + retries: 30 + + locks-server: + build: + context: . + dockerfile: Dockerfile + + depends_on: + postgres: + condition: service_healthy + pubky-testnet: + condition: service_started + network_mode: service:pubky-testnet + environment: + RUST_LOG: ${LOCKS_RUST_LOG:-info,pubky::actors::session=warn} + LOCKS_PUBLIC_CONFIG: /run/locks-public/config.toml + volumes: + - lock-home:/var/lib/pubky-lock + - lock-public:/run/locks-public + - ./.local/locks-server:/run/compose-local/locks-server:ro + command: + - /bin/sh + - -euc + - | + set -a + . /run/compose-local/locks-server/compose.env + set +a + exec locks-server-compose-entrypoint.sh + healthcheck: + test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:3000/readyz >/dev/null"] + interval: 2s + timeout: 5s + retries: 60 + + paykit-config: + image: node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 + user: "1000:1000" + working_dir: /workspace + command: + - node + - examples/js-sdk/scripts/init-paykit-compose.mjs + - --config-only + - --lock-config + - /run/locks-public/config.toml + volumes: + - ./examples/js-sdk:/workspace/examples/js-sdk:ro + - ./.local/paykit-config:/workspace/.local/paykit-config + - lock-public:/run/locks-public:ro + depends_on: + locks-server: + condition: service_healthy + + demo-config: + image: node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 + user: "1000:1000" + working_dir: /workspace + command: + - node + - examples/js-sdk/scripts/init-config.mjs + - --lock-config + - /run/locks-public/config.toml + volumes: + - ./examples/js-sdk:/workspace/examples/js-sdk:ro + - ./.local/demo-config:/workspace/.local/demo-config + - lock-public:/run/locks-public:ro + depends_on: + locks-server: + condition: service_healthy + + paykit-server: + image: pubky-locks-paykit-server:local + build: + context: "https://github.com/pubky/paykit-server.git#5ed3e8e849a16045c26c37a75068625dda333785" + dockerfile: Dockerfile.local + additional_contexts: + paykit-lib: "https://github.com/pubky/paykit-rs.git#6b241878a9bba5cecea919c0298c3f90624be6ff:paykit-lib" + paykit-sdk: "https://github.com/pubky/paykit-rs.git#6b241878a9bba5cecea919c0298c3f90624be6ff:paykit-sdk" + locks: "https://github.com/pubky/locks.git#df5ea1b6d8dcdec3a9b5a915c3f57bca69d75c8a" + + depends_on: + paykit-postgres: + condition: service_healthy + pubky-testnet: + condition: service_started + paykit-config: + condition: service_completed_successfully + electrum-readiness: + condition: service_completed_successfully + network_mode: service:pubky-testnet + user: "1000:1000" + environment: + PAYKIT_CONFIG: /etc/paykit-server/config.toml + RUST_LOG: ${RUST_LOG:-info} + entrypoint: ["/bin/bash", "-euc"] + command: + - | + set -a + . /run/compose-local/paykit-server/paykit.env + set +a + exec /usr/local/bin/paykit-server + volumes: + - ./.local/paykit-config:/etc/paykit-server:ro + - ./.local/paykit-server:/run/compose-local/paykit-server:ro + healthcheck: + test: + - CMD + - /bin/bash + - -ec + - >- + check() { + exec 3<>/dev/tcp/127.0.0.1/3001; + printf 'GET %s HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' "$$1" >&3; + IFS= read -r status <&3; + exec 3<&- 3>&-; + [[ "$$status" == *" 200 "* ]]; + }; + check /health/live && check /health/ready + interval: 2s + timeout: 5s + retries: 90 + + creator-demo: + build: + context: . + dockerfile: docker/js-demo.Dockerfile + additional_contexts: + paykit-runtime: service:paykit-server + working_dir: /workspace + user: "1000:1000" + depends_on: + locks-server: + condition: service_healthy + paykit-server: + condition: service_healthy + demo-config: + condition: service_completed_successfully + network_mode: service:pubky-testnet + environment: + LOCKS_INTERNAL_LOCK_SERVER_URL: http://127.0.0.1:3000 + LOCKS_INTERNAL_HTTP_RELAY: http://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: + - ./.local/demo-config:/workspace/.local/demo-config:ro + - ./.local/js-sdk-demo:/workspace/.local/js-sdk-demo + - ./.local/creator-public:/workspace/.local/creator-public + command: + - sh + - -lc + - | + set -eu + rm -f /workspace/.local/creator-public/profile.json + npm --prefix examples/js-sdk run start-server -- --external-wallet + + reader-demo: + restart: unless-stopped + build: + context: . + dockerfile: docker/js-demo.Dockerfile + additional_contexts: + paykit-runtime: service:paykit-server + working_dir: /workspace + user: "1000:1000" + depends_on: + locks-server: + condition: service_healthy + paykit-server: + condition: service_healthy + demo-config: + condition: service_completed_successfully + network_mode: service:pubky-testnet + environment: + LOCKS_INTERNAL_LOCK_SERVER_URL: http://127.0.0.1:3000 + LOCKS_INTERNAL_HTTP_RELAY: http://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" + PAYKIT_EXTERNAL_READER_PUBKY: ${PAYKIT_EXTERNAL_READER_PUBKY:-} + PUBKY_LOCK_DEBUG: ${PUBKY_LOCK_DEBUG:-0} + volumes: + - ./.local/demo-config:/workspace/.local/demo-config:ro + - ./.local/creator-public:/workspace/.local/creator-public:ro + - ./.local/content-viewer:/workspace/.local/content-viewer + - ./.local/paykit-reader:/workspace/.local/paykit-reader + command: + - sh + - -euc + - | + if [ -z "$PAYKIT_EXTERNAL_READER_PUBKY" ]; then + npm --prefix examples/js-sdk run create-user -- --role content-viewer + fi + exec node examples/js-sdk/scripts/start-reader-demo-server.mjs + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://127.0.0.1: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..3eb9440 --- /dev/null +++ b/docker/js-demo.Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e +FROM paykit-runtime AS paykit-runtime + +FROM rust:1.91.1-slim-bookworm@sha256:8514999d4786ef12efe89239e86b3d0a021b94b9d35108c8efe6c79ca7dc1a65 AS locks-sdk-wasm +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential ca-certificates libssl-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* +RUN rustup target add wasm32-unknown-unknown \ + && cargo install wasm-pack --version 0.13.1 --locked +WORKDIR /workspace +COPY . . +RUN cd locks-sdk/bindings/js && wasm-pack build --target web --out-dir pkg + +FROM node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 +WORKDIR /workspace +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates util-linux \ + && rm -rf /var/lib/apt/lists/* +COPY --chown=node:node examples/js-sdk/package.json examples/js-sdk/package-lock.json /workspace/examples/js-sdk/ +RUN npm --prefix examples/js-sdk ci --ignore-scripts \ + && npm cache clean --force +COPY --chown=node:node examples/js-sdk /workspace/examples/js-sdk +COPY --from=locks-sdk-wasm --chown=node:node /workspace/locks-sdk/bindings/js/pkg /workspace/locks-sdk/bindings/js/pkg +COPY --from=paykit-runtime /usr/local/bin/paykit-companion-auth /usr/local/bin/paykit-companion-auth +COPY --from=paykit-runtime /usr/local/bin/paykit-reader-demo /usr/local/bin/paykit-reader-demo +RUN mkdir -p /workspace/.local \ + && chown -R node:node /workspace +USER node:node diff --git a/docker/locks-server-compose-entrypoint.sh b/docker/locks-server-compose-entrypoint.sh index ed28851..e114aec 100644 --- a/docker/locks-server-compose-entrypoint.sh +++ b/docker/locks-server-compose-entrypoint.sh @@ -6,6 +6,7 @@ generated_config="$service_home/config.toml" compose_config="${LOCKS_COMPOSE_CONFIG:-/var/lib/pubky-lock/config.compose.toml}" secret_path="$service_home/secret.sess" creator_authority_key_path="$service_home/creator-authority-encryption-key" +public_config="${LOCKS_PUBLIC_CONFIG:-/run/locks-public/config.toml}" mkdir -p "$service_home" @@ -46,6 +47,12 @@ if [ -z "$lock_server_public_key" ] || [ "$lock_server_public_key" = " "$public_config_tmp" +chmod 0644 "$public_config_tmp" +mv "$public_config_tmp" "$public_config" + cat > "$compose_config" <", "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 } ``` - `recipient_pubky` must equal the canonical content-lock creator. - `amount` is a positive decimal integer string in the asset's base unit. - `asset` is an opaque, non-empty string to Locks. Paykit Server owns deployment-specific asset support and base-unit interpretation. +- `payment_in` is a required, nonzero JSON `u64` number of whole hours in Locks policy. - V1 permits exactly one payment criterion, referenced exactly once by the lock logic, and exactly one submitted payment proof. - The submitted payment proof payload is `{}`. `reader_public_key` is top-level submission data. - Content-lock authoring does not require runtime Paykit configuration or availability. diff --git a/docs/API.md b/docs/API.md index 516ebd3..5ce85ed 100644 --- a/docs/API.md +++ b/docs/API.md @@ -24,6 +24,7 @@ The Lock Server has one non-production route family and one authenticated creato - Can run in `development`, `staging`, or `production`. - Require `Authorization: Bearer `. - Derive creator identity from the frontend session. Request-body `creator` is rejected for authenticated routes. + - A guarded path can be owned by only one managed Content Lock for that creator. Creating a different Lock ID for an owned path returns `409 content_lock_path_conflict`. - Missing/unknown/expired frontend sessions use the JSON error envelope (`401 frontend_session_unavailable` or `401 frontend_session_expired`). - Missing/revoked creator-granted homeserver authority remains a separate operational error (`503 creator_authority_unavailable`). - Creator authority status route: `GET /creator/authority-status` @@ -46,7 +47,7 @@ Gated-off routes are plain Axum `404 Not Found` responses because the route is i | --- | --- | --- | --- | --- | | `PUT /creator/priv-resources/content/` | `200` JSON guarded-resource descriptor | Requires `Authorization: Bearer `. Raw bytes body; MIME from `Content-Type`. | No bearer secrets or raw bytes in response. | `400 invalid_request`, `401 frontend_session_unavailable`, `401 frontend_session_expired`, `413 payload_too_large`, `503 creator_authority_unavailable` | | `DELETE /creator/priv-resources/content/` | `204` empty response | Requires `Authorization: Bearer `. | No bearer secrets or raw bytes in response. | `401 frontend_session_unavailable`, `401 frontend_session_expired`, `404 guarded_resource_not_found`, `503 creator_authority_unavailable` | -| `POST /creator/content-locks` | `200` JSON content lock | Requires `Authorization: Bearer `. | No bearer secrets in response. | `400 invalid_request`, `404 guarded_resource_not_found`, `401 frontend_session_unavailable`, `401 frontend_session_expired`, `503 creator_authority_unavailable` | +| `POST /creator/content-locks` | `200` JSON content lock | Requires `Authorization: Bearer `. | No bearer secrets in response. | `400 invalid_request`, `401 frontend_session_unavailable`, `401 frontend_session_expired`, `404 guarded_resource_not_found`, `409 content_lock_path_conflict`, `503 creator_authority_unavailable` | | `POST /creator/lock-service-config` | `200` JSON lock-service pointer | Requires `Authorization: Bearer `. | No bearer secrets in response. | `400 invalid_request`, `401 frontend_session_unavailable`, `401 frontend_session_expired`, `503 creator_authority_unavailable` | | `GET /connect` | `200` HTML Lock-Server-hosted connect shell | No bearer auth. Mounted when `[creator_authority_acquisition].enabled = true`; `return_to` must match `allowed_return_origins` or explicit wildcard policy. | HTML intentionally contains the secret-bearing Pubky authorization URL on Lock Server origin; response must not contain frontend session token, one-time code, or creator authority secret. | `400 invalid_request`, `503 creator_authority_unavailable`, `404` when route gated off | | `POST /connect/{flow_id}/complete` | `303` redirect to stored `return_to` | No bearer auth. Mounted when `[creator_authority_acquisition].enabled = true`; stored `return_to` is revalidated before redirect. | `Location` contains only callback `state` and one-time `code`; no authorization URL, frontend session token, or creator authority secret. | `400 invalid_request`, `404 creator_connect_flow_unavailable`, `410 creator_connect_flow_expired`, `503 creator_authority_unavailable`, `404` when route gated off | @@ -98,6 +99,7 @@ Stable error codes and statuses mirror `locks-server/src/api/errors.rs` tests: | `frontend_session_expired` | 401 | Frontend session token existed but expired. | | `frontend_session_state_mismatch` | 400 | One-time code exchange state did not match. | | `creator_authority_unavailable` | 503 | Creator-granted homeserver authority is unavailable or could not be revalidated. | +| `content_lock_path_conflict` | 409 | The creator-scoped guarded path already has an in-flight or published Content Lock owner. | | `task_state_conflict` | 409 | Submission or completion conflicts with existing task state. | | `unsupported_verifier_type` | 422 | Proof references a verifier unavailable in the current runtime. | | `paykit_not_configured` | 422 | A `paykit-payment` proof was submitted to a Lock Server without a `[paykit]` runtime section. | @@ -348,11 +350,12 @@ Every referenced guarded resource must currently exist for the same creator/path { "recipient_pubky": "pubky", "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 } ``` -`recipient_pubky` must be a valid Pubky public key string equal to the content-lock creator, `amount` must be a positive base-unit integer encoded as a string, and `asset` must be a non-empty string. The lock params do not include Paykit server URLs, account IDs, memos, expiry, payment references, or reader identity. A v1 content lock that uses `paykit-payment` must contain exactly that one criterion, and its `all` or `any` lock logic must reference that criterion exactly once. Mixed criteria, multiple payment criteria, recipient/creator mismatch, and duplicate or mismatched logic references return `400 invalid_request`. +`recipient_pubky` must be a valid Pubky public key string equal to the content-lock creator, `amount` must be a positive base-unit integer encoded as a string, `asset` must be a non-empty string, and `payment_in` must be a positive whole-hour JSON `u64`. The lock params do not include Paykit server URLs, account IDs, memos, expiry, payment references, or reader identity. A v1 content lock that uses `paykit-payment` must contain exactly that one criterion, and its `all` or `any` lock logic must reference that criterion exactly once. Mixed criteria, multiple payment criteria, recipient/creator mismatch, and duplicate or mismatched logic references return `400 invalid_request`. #### Request diff --git a/docs/LOCAL_OPERATOR_DEMO.md b/docs/LOCAL_OPERATOR_DEMO.md index e0b214c..fb12e9f 100644 --- a/docs/LOCAL_OPERATOR_DEMO.md +++ b/docs/LOCAL_OPERATOR_DEMO.md @@ -11,6 +11,60 @@ Creator publishing is authenticated. The removed unauthenticated local/dev creat - [`docs/RUNTIME.md`](RUNTIME.md): startup/config/worker/health-readiness behavior. - [`docs/API.md`](API.md): route contract and fixture-backed request/response shapes. - [`docs/LOCAL_DEMO.md`](LOCAL_DEMO.md): E2E/test-support client demo using Axum `Router::oneshot`. +- [`examples/js-sdk/README.md`](../examples/js-sdk/README.md): browser-facing Paykit local demo setup and operator commands. + +## Paykit Compose local demonstration + +The repository's browser-facing Paykit demonstration is a separate operator path from the manual single-server walkthrough below. Its local-only definition is `compose.paykit-local-demo.yaml`. It composes PostgreSQL, Bitcoin regtest, Fulcrum, Pubky testnet, Locks, Paykit Server, and the creator and reader browser demos. External source builds use anonymous public Git contexts pinned to immutable commits; no sibling repository checkout is required. + +The startup dependency flow is: + +```mermaid +flowchart TD + composeBootstrap[compose-bootstrap] --> postgres + composeBootstrap --> paykitPostgres[paykit-postgres] + composeBootstrap --> bitcoin + + postgres --> pubkyTestnet[pubky-testnet] + postgres --> locksServer[locks-server] + pubkyTestnet --> locksServer[locks-server] + + bitcoin --> bitcoinBootstrap[bitcoin-bootstrap] + bitcoinBootstrap --> fulcrum + fulcrum --> electrumReadiness[electrum-readiness] + + locksServer --> paykitConfig[paykit-config] + locksServer --> demoConfig[demo-config] + + paykitPostgres --> paykitServer[paykit-server] + pubkyTestnet --> paykitServer + paykitConfig --> paykitServer + electrumReadiness --> paykitServer + + locksServer --> creatorDemo[creator-demo] + paykitServer --> creatorDemo + demoConfig --> creatorDemo + pubkyTestnet --> creatorDemo + + locksServer --> readerDemo[reader-demo] + paykitServer --> readerDemo + demoConfig --> readerDemo + pubkyTestnet --> readerDemo +``` + +`creator-demo` and `reader-demo` wait for healthy `locks-server` and `paykit-server` services plus successful `demo-config` completion. + +The bootstrap and configuration services are one-shot startup jobs. Exiting successfully is their healthy terminal state; they do not remain as long-running processes: + +| Service | Responsibility | Downstream gate | +| --- | --- | --- | +| `compose-bootstrap` | Creates or validates ignored local credentials, service environment files, Pubky homeserver configuration, state directories, ownership, and permissions. | Both PostgreSQL services and Bitcoin start only after successful completion. | +| `bitcoin-bootstrap` | Waits for regtest RPC, creates or loads the `miner` wallet, and mines to height 101 so coinbase funds are mature and spendable. | Fulcrum starts only after successful completion. | +| `electrum-readiness` | Sends an Electrum `server.version` request and validates the response; a started container or open TCP port alone is insufficient. | Paykit Server starts only after protocol readiness succeeds. | +| `paykit-config` | Waits for Locks Server to publish its runtime public key, then generates Paykit Server configuration that trusts that exact identity. | Paykit Server starts only after successful generation. | +| `demo-config` | Generates the shared browser configuration from the runtime Locks Server identity and local testnet endpoints. | Creator and reader demos start only after successful generation. | + +The complete startup and reset commands, browser URLs, local state boundaries, and manual payment workflow are maintained in [`examples/js-sdk/README.md`](../examples/js-sdk/README.md). ## Dev legacy-connect testnet automation diff --git a/docs/plans/2026-08-10-graceful-content-lock-deletion.md b/docs/plans/2026-08-10-graceful-content-lock-deletion.md new file mode 100644 index 0000000..6f6402b --- /dev/null +++ b/docs/plans/2026-08-10-graceful-content-lock-deletion.md @@ -0,0 +1,496 @@ +# Graceful Content-Lock Deletion and Payment Deadline Implementation Plan + +> **For Hermes:** Use subagent-driven-development to implement this plan one review-gated commit slice at a time. Stop after each slice; the user commits before the next slice. + +**Goal:** Add a bounded `paykit-payment` deadline and creator-authorized graceful content-lock deletion that withdraws the public lock immediately, drains accepted payment and access obligations durably, removes guarded content, and safely permits later republication after complete graceful cleanup. + +**Architecture:** Locks owns the public tombstone, admission cutoff, verification tasks, credentials, guarded content, path ownership, and overall deletion job. Paykit Server owns invoice timestamps, Payment Request lifecycle classification, cancellation, Bitcoin observation, and a durable lock-wide payment drain. PostgreSQL stores retryable Locks workflow state; Pubky remains authoritative for public lock/tombstone and private guarded bytes. + +**Tech Stack:** Rust 2024, Axum, Tokio, SQLx/PostgreSQL, Pubky homeserver storage, `time`, AEAD via `chacha20poly1305`, existing Locks SDK and JS/WASM bindings. + +**Sibling plan:** Paykit Server `docs/plans/2026-08-10-lock-payment-draining.md`. Both plans repeat the shared wire contract deliberately. + +--- + +## Status and provenance + +- Plan status: **accepted product design; implementation not started**. +- Repository inspected: `/home/u/Projects/Synonym/Pubky/locks-public`. +- Planning base when written: clean `master` at `ba49a77`. +- There has been no production deployment. New persistence may require a clean pre-production database; no historical backfill is required. +- No Paykit Rust protocol change is planned. `proposal_expires_at` retains its existing pre-acceptance meaning. + +### Explicit requirements and confirmed decisions + +1. `paykit-payment` criterion params gain required `payment_in`. +2. `payment_in` is a nonzero JSON `u64` integer measured in whole hours. Zero, fractional, negative, string, and out-of-range values are invalid. There is no product maximum beyond checked duration/timestamp representation. +3. Locks includes `payment_in` in the signed invoice request. Paykit independently reads the canonical lock and rejects a mismatch before side effects. +4. Paykit commits `invoice_created_at` and `payment_deadline = checked(invoice_created_at + payment_in hours)` atomically with invoice creation. Exact replay returns the original timestamps. +5. Locks persists the returned timestamps before admitting the verification task. Retry never restarts the payment window. +6. Paykit sets Payment Request `proposal_expires_at` to `payment_deadline`, but the field remains proposal-only. Locks and Paykit Server enforce the post-acceptance deadline as application state. +7. Payment is timely when Paykit’s durable `first_amount_matched_observed_at <= payment_deadline`. An earlier underpayment does not lend its timestamp to a later qualifying output. Polling latency is accepted. +8. At the deadline, undetected and underpaid invoices expire and stop active observation. A timely amount-matched payment may continue confirmation observation after the deadline without a second timeout. +9. Locks alone applies configured `minimum_confirmations`; it is not sent to Paykit’s drain endpoint. +10. Payment after application expiry never opens the lock. Reader UI blocks/removes payment instructions at the deadline and warns that late payment receives no access or automatic refund. +11. Graceful deletion is the default creator DELETE mode. `graceful=true` is an alias; `force=true` is mutually exclusive and explicit. +12. Graceful deletion is irreversible once its durable job is persisted. There is no cancellation API. +13. Public withdrawal replaces `/pub/locks.app/{lock_id}.json` with an exact tombstone after durably storing the original canonical lock: + +```json +{ + "version": 1, + "type": "content_lock_deletion", + "lock_id": "", + "deletion_started_at": "" +} +``` + +14. Persisting the deletion job is the proof-admission cutoff. New Bundle IDs are rejected; exact replay/status for previously persisted tasks remains available. +15. Paykit atomically classifies Payment Requests at drain start: accepted/rejected persisted before the cutoff retain that state; unanswered requests are durably canceled; later acceptance loses. +16. Durable cancellation enqueue is enough to stop blocking; delivery/acknowledgment is not awaited. +17. Rejected and canceled requests do not block. Accepted requests block until payment expires or satisfies Locks’ frozen rule. Timely amount-matched payment continues through required confirmations. +18. Existing access credentials remain reusable until their original expiry. +19. Existing and final drain credentials resolve authorization and resource descriptors from the deletion job’s frozen canonical manifest while the public path contains a tombstone. The tombstone is never treated as a valid Content Lock, and callers outside the persisted drain receive no new access. +20. Every already-paid entitlement lacking an active credential at tombstoning, plus every payment completed during draining, may obtain exactly one final drain credential. +21. Default final-credential issuance window is 15 minutes; configured maximum is one hour. Default read window is 15 minutes; configured maximum is one hour. Retry does not extend either. +22. Final credential permits one successful GET per frozen resource. Each path uses an atomic claim. Consumption occurs after upstream bytes are fetched/validated and a `200` response is constructed; a later disconnect does not restore it. Pre-response fetch failure releases the claim. +23. Exact credential issuance replay returns the same random bearer. Persist a versioned encrypted envelope using a domain-separated key derived from the existing runtime master key; bind creator, Bundle ID, deletion job, and envelope version as AEAD context. +24. Deletion worker retries transient failures with durable exponential backoff: one second initial, five-minute cap, full jitter, ten attempts per phase by default. Attempts reset on phase advance. +25. Creator-visible job status is only `queued|running|completed|failed`; failed responses include a stable secret-free `failure_code` only. +26. Missing or replaced tombstone before destructive work halts as failed. Creator restores the exact tombstone and repeats graceful DELETE to resume the same job. +27. Guarded paths are exclusive to one managed lock. Enforce unique `(creator, guarded_path)` ownership in PostgreSQL. There is no historical backfill. +28. Lock publication uses best-effort reservation compensation and accepts crash-orphaned ownership requiring operator cleanup; do not claim cross-system atomicity. +29. Graceful final cleanup deletes guarded content first and tombstone last, purges Locks authorization/task/job state, asks Paykit to remove operational drain state, and releases path ownership. It forgets the deletion so the same canonical Lock ID may later be published fresh with new Bundle IDs. +30. Paykit retains terminal financial invoice/payment history; delayed old lifecycle events cannot reactivate a fresh publication. +31. New force deletion is synchronous: persist a permanent minimal blocking receipt, delete lock/tombstone first, then best-effort guarded resources. Do not drain Paykit/tasks/credentials. Return failed paths. A force-deleted Lock ID can never be republished. +32. `force=true` against an active graceful job persists `force_requested` and returns `202`; the worker escalates asynchronously under exclusive action ownership, skips drains, deletes tombstone then content, and finishes forced. + +### Source-derived constraints + +- Lock ID is BLAKE3 over complete canonical lock JSON. A mutable `deleting` field cannot be added under the same ID. +- Readers fetch the public lock directly from the creator homeserver; a Locks Server GET gate cannot withdraw it. +- Guarded content and public lock JSON are separate Pubky records; no delete cascade exists. +- Current creation validates resource descriptors but does not enforce cross-lock path exclusivity (`locks-service/src/application/use_cases/create_content_lock.rs`). +- Current verification tasks use PostgreSQL leases and fresh claim tokens; deletion needs a separate queue but the same fenced-transition discipline. +- Current access-credential storage keeps only a bearer lookup hash; exact replay requires new encrypted bearer persistence. +- `proposal_expires_at` expires only `Proposed` Paykit SDK state and has no accepted-payment effect. +- Pubky, Locks PostgreSQL, and Paykit PostgreSQL cannot participate in one atomic transaction. + +### Explicitly accepted risks + +- Late Bitcoin payment may receive no content and no refund. +- Paykit polling latency can make a pre-deadline broadcast late. +- Timely amount-matched payment can block deletion indefinitely while confirmations/reorg state remains unresolved. +- Durable cancellation enqueue may precede actual counterparty delivery. +- Best-effort lock-publication reservation compensation can leave operator-cleaned orphan ownership after process death. +- Force deletion deliberately abandons active payment/access obligations and may orphan content after a crash. + +## Repository ownership matrix + +| Contract/state | Owner | +| --- | --- | +| `payment_in` criterion schema and validation | Locks Core | +| Signed invoice request producer and response persistence | Locks Server/Service | +| `invoice_created_at`, `payment_deadline`, proposal expiry | Paykit Server | +| Payment Request acceptance/rejection/cancellation projection | Paykit Server | +| Bitcoin first-observation and confirmations | Paykit Server | +| `minimum_confirmations` entitlement decision | Locks | +| Public tombstone and frozen lock manifest | Locks | +| Proof admission cutoff and task transitions | Locks | +| Credentials, per-path consumption, content serving | Locks | +| Lock-wide payment drain and aggregate status | Paykit Server | +| Overall deletion orchestration and final cleanup | Locks | +| Terminal financial history | Paykit Server | + +## Shared service-to-service contract + +All requests use existing `X-Paykit-Signature` over canonical JSON. Secret/correlation identifiers stay in POST bodies and must not be logged. + +### Invoice creation + +```http +POST /invoices + +{ + "bundle_id": "...", + "lock_resource": "pubky.../pub/locks.app/.json", + "reader": "pubky...", + "payment_in": 24 +} +``` + +Success changes from ignored-body 2xx to closed JSON: + +```json +{ + "invoice_created_at": "", + "payment_deadline": "" +} +``` + +Paykit compares request `payment_in` with canonical criterion `payment_in`. Exact replay returns the original response. + +### Lock-wide drain + +```http +POST /payment-request-drains +{ "lock_resource": "..." } +``` + +Starts or exactly replays an atomic persisted classification. No `minimum_confirmations` field. + +```http +POST /payment-request-drain-lookups +{ "lock_resource": "..." } +``` + +Returns aggregate factual state only; no Bundle IDs, readers, Payment Request IDs, addresses, or raw errors. + +### Per-Bundle status + +```http +POST /payment-requests/status +{ "creator": "pubky...", "bundle_id": "..." } +``` + +Returns orthogonal `request_state` and `payment_state`, immutable invoice/deadline timestamps, confirmations, and amount match. Exact enum spellings and invalid/recovery-state HTTP mapping are an implementation-contract gate and must be synchronized in both plans before code. + +### Drain cleanup + +Paykit Server needs an idempotent signed operation to remove only the completed operational drain row after Locks has completed all external deletion effects. Exact route/body is an implementation-contract gate; it must not remove financial invoice/payment history. + +## HTTP creator contract + +```http +DELETE /creator/content-locks/{lock_id} +DELETE /creator/content-locks/{lock_id}?graceful=true +``` + +Starts/replays/resumes graceful deletion and returns `202` for queued/running work. A completed-and-forgotten absent lock is an idempotent absent postcondition. + +```http +DELETE /creator/content-locks/{lock_id}?force=true +``` + +- No graceful job: synchronous `200` force summary. +- Existing graceful job: persist `force_requested`, return `202` job status. + +Reject `force=true&graceful=true`, unknown fields, malformed booleans, and duplicate conflicting query values. + +```http +GET /creator/content-locks/{lock_id}/deletion +``` + +Authenticated response contains Lock ID and `status`; include `failure_code` only for failed jobs. Do not expose phases, leases, retries, Bundle IDs, readers, credentials, paths, Paykit IDs, or dependency errors. + +## Internal state model + +Internal phase names are not public API. The implementation should represent at least: + +1. `withdraw`: persist frozen payload/job/admission cutoff, write tombstone, read back exact bytes. +2. `start_payment_drain`: exact Paykit drain creation. +3. `drain_payments`: poll aggregate drain and per-Bundle statuses; transition frozen tasks. +4. `drain_existing_credentials`: wait for credentials active at cutoff to expire. +5. `issue_final_credentials`: allow bounded issuance for eligible entitlements. +6. `drain_final_reads`: enforce per-path claims/consumption and read deadlines. +7. `delete_content`: idempotently delete every frozen resource while tombstone remains exact. +8. `delete_tombstone`: persist intent-to-remove phase before external delete so missing-on-retry is success. +9. `purge_operational_state`: remove Paykit operational drain, then atomically purge Locks lock-scoped authorization/task/job state and release path ownership. + +Use separate durable `state`, `phase`, `attempt_count`, `next_attempt_at`, claim owner/token/expiry, and force-request fields. Use a per-job PostgreSQL advisory action lock where lease expiry must not permit overlapping external effects. SQLx advisory-lock connections must be close-on-drop and explicitly unlocked/closed. + +## Implementation sequence + +Each task is a separate review/commit checkpoint. Do not commit automatically. + +### Task 1: Lock the `payment_in` core contract + +**Objective:** Make the content-addressed lock schema reject every non-approved timing shape. + +**Files:** +- Modify: `locks-core/src/lock_policy.rs` +- Modify: `locks-core/src/creator_publishing.rs` +- Modify: `locks-sdk/bindings/js/src/creator.rs` +- Test: neighboring unit/public API tests in those files and `locks-sdk/tests/public_api.rs` + +**RED:** Add serialization/validation tests for required nonzero JSON `u64`, unknown/missing field rejection, zero/fraction/string/overflow rejection, and canonical Lock ID sensitivity. + +**GREEN:** Extend the closed `paykit-payment` params parser/typed accessors and JS creator builder. + +**Verify:** + +```bash +cargo test -p locks-core +cargo test -p locks-sdk +cargo test -p locks-sdk-wasm +cargo test --workspace --no-run +``` + +**Suggested commit:** `feat(core): add paykit payment deadline hours` + +### Task 2: Persist exclusive guarded-path ownership + +**Objective:** Enforce one managed Content Lock per creator/path and retain ownership safely across deletion failures. + +**Files:** +- Modify: `locks-service/src/infrastructure/postgres/migrations.rs` +- Create: `locks-service/src/application/models/content_lock_ownership.rs` +- Create: `locks-service/src/application/ports/content_lock_ownership.rs` +- Create: `locks-service/src/infrastructure/postgres/content_lock_ownership.rs` +- Modify: relevant `mod.rs` exports +- Modify: `locks-service/src/application/use_cases/create_content_lock.rs` +- Modify: in-memory test adapters +- Test: `locks-e2e/tests/postgres_runtime.rs` +- Test: `locks-e2e/tests/production_creator_publishing_http.rs` + +**RED:** Prove duplicate `(creator,path)` rejection, atomic all-path reservation, ordinary-error compensation, retained ownership after failed deletion, and clean-database rollout. + +**GREEN:** Add unique ownership rows carrying creator, full path, intended Lock ID, and status. Reserve before Pubky publication; best-effort compensate ordinary publication failure. Do not invent historical backfill. + +**Verify:** + +```bash +TEST_DATABASE_URL="$TEST_DATABASE_URL" cargo test -p locks-e2e --test postgres_runtime +TEST_DATABASE_URL="$TEST_DATABASE_URL" cargo test -p locks-e2e --test production_creator_publishing_http +cargo test --workspace --no-run +``` + +**Suggested commit:** `feat(service): enforce guarded path ownership` + +### Task 3: Upgrade the Locks-to-Paykit invoice boundary + +**Objective:** Send `payment_in`, require the closed timestamp response, and durably bind it to the verification task before admission. + +**Files:** +- Modify: `locks-server/src/paykit_http_client.rs` +- Modify: `locks-service/src/application/models/verification.rs` +- Modify: `locks-service/src/application/ports/verification.rs` +- Modify: verification task PostgreSQL/memory adapters and migration +- Modify: `locks-service/src/application/use_cases/submit_proof_bundle.rs` +- Test: `locks-server/src/api/routes/tests.rs` +- Test: `locks-e2e/tests/postgres_runtime.rs` + +**Dependency gate:** Implement only after the Paykit Server invoice-response slice is reviewed and committed. + +**RED:** Test canonical signed request body, strict timestamp response decoding, checked ordering (`created <= deadline`), exact task replay preserving timestamps, and rollback/no-task on invoice rejection. + +**GREEN:** Persist immutable invoice timestamps with the task in the same local transaction that admits it. Do not recompute on retry. + +**Verify:** focused unit tests, PostgreSQL E2E, then `cargo test --workspace --no-run`. + +**Suggested commit:** `feat(paykit): persist invoice payment deadlines` + +### Task 4: Add deletion/tombstone domain and persistence + +**Objective:** Persist frozen manifests, cutoff state, leases, retry scheduling, force receipts, and minimal public DTOs. + +**Files:** +- Create: `locks-core/src/content_lock_deletion.rs` +- Modify: `locks-core/src/lib.rs` +- Create: `locks-service/src/application/models/content_lock_deletion.rs` +- Create: `locks-service/src/application/ports/content_lock_deletion.rs` +- Create: `locks-service/src/infrastructure/postgres/content_lock_deletions.rs` +- Create: `locks-service/src/infrastructure/memory/content_lock_deletions.rs` +- Modify: PostgreSQL migration/module exports +- Modify: `locks-service/src/application/errors.rs` + +**RED:** Test exact tombstone JSON, strict unknown-field rejection, frozen payload integrity, unique creator/Lock ID job identity, due claims, lease reclaim/fresh tokens, stale-token rejection, per-phase attempt reset, and permanent force receipt. + +**GREEN:** Implement the minimal state model. Keep public status conversion separate from internal phases. + +**Suggested commit:** `feat(service): persist content lock deletion jobs` + +### Task 5: Serialize deletion start against proof admission + +**Objective:** Make database commit order the authoritative cutoff for new Bundle IDs. + +**Files:** +- Modify: `locks-service/src/application/use_cases/submit_proof_bundle.rs` +- Create: `locks-service/src/application/use_cases/start_content_lock_deletion.rs` +- Modify: relevant repositories/PostgreSQL transaction helpers +- Test: `locks-e2e/tests/postgres_runtime.rs` +- Test: `locks-server/src/api/routes/tests.rs` + +**RED:** Concurrent tests prove task-first commit joins snapshot, deletion-first commit rejects a new Bundle, exact old replay succeeds, and conflicting replay remains rejected. + +**GREEN:** Use per-lock database serialization and one transaction for job persistence/task snapshot. Do not use viewer timestamps or tombstone publication as the cutoff. + +**Suggested commit:** `feat(service): enforce deletion admission cutoff` + +### Task 6: Add creator deletion/status APIs and SDKs + +**Objective:** Expose authenticated graceful default, explicit force, and minimal status consistently across Rust and JS. + +**Files:** +- Modify: `locks-server/src/api/creator_publishing.rs` +- Modify: `locks-server/src/api/dtos.rs` +- Modify: `locks-server/src/api/errors.rs` +- Modify: `locks-server/src/api/routes.rs` +- Modify: `locks-sdk/src/creator.rs` +- Modify: `locks-sdk/src/transport.rs` +- Modify: `locks-sdk/bindings/js/src/creator.rs` +- Test: `locks-server/src/api/routes/tests.rs` +- Test: `locks-sdk/tests/public_api.rs` +- Test: `locks-e2e/tests/production_creator_publishing_http.rs` + +**RED:** Cover query matrix, auth creator binding, 202 replay/resume/escalation, synchronous 200 force, permanent force receipt, absent postcondition, and redacted status. + +**GREEN:** Implement the closed routes exactly as documented. No immediate force through an omitted query option. + +**Suggested commit:** `feat(api): add creator content lock deletion` + +### Task 7: Integrate Paykit drain/status client + +**Objective:** Start/poll Paykit’s lock-wide drain and resolve each existing verification task from factual status. + +**Files:** +- Modify: `locks-server/src/paykit_http_client.rs` +- Modify: `locks-server/src/app_state/mod.rs` +- Create: `locks-service/src/application/ports/payment_drain.rs` +- Create: `locks-service/src/application/use_cases/drain_lock_payments.rs` +- Test: `locks-server/src/paykit_http_client.rs` +- Test: deletion use-case tests and HTTP integration fixtures + +**Dependency gate:** Patch both plans with exact per-Bundle enums, error mappings, and drain-cleanup route before RED tests. Then implement Paykit Server routes first. + +**RED:** Test exact signed JSON, no `minimum_confirmations` leak, aggregate redaction, local application of confirmations, canceled/rejected/expired transitions, timely matched confirmation continuation, and retryable transport errors. + +**Suggested commit:** `feat(paykit): drain deleting lock payments` + +### Task 8: Implement final credential/read draining + +**Objective:** Preserve existing credential TTL behavior while giving eligible paid entitlements one bounded per-resource final read. + +**Files:** +- Modify: `locks-service/src/application/models/access.rs` +- Modify: `locks-service/src/application/ports/access.rs` +- Modify: `locks-service/src/infrastructure/postgres/access_credentials.rs` +- Modify: `locks-service/src/infrastructure/postgres/migrations.rs` +- Modify: `locks-service/src/application/use_cases/issue_access_credential.rs` +- Modify: `locks-service/src/application/use_cases/proxy_read_guarded_resource.rs` +- Modify: `locks-server/src/storage.rs` and secret composition as needed +- Test: `locks-service/src/application/use_cases/credential_flow_tests.rs` +- Test: `locks-service/src/application/use_cases/retrieval_access_flow_tests.rs` +- Test: `locks-e2e/tests/retrieval_access_http.rs` + +**RED:** Cover exact encrypted replay, wrong-key/corrupt/version rejection, no secret Debug/log output, issuance/read deadlines, no deadline extension, existing/final access through the frozen manifest while the public path is a tombstone, denial outside the persisted drain, one concurrent success per path, claim release before response construction, consumption after construction, and automatic revocation when complete/expired. + +**GREEN:** Use versioned AEAD and domain-separated key derivation; retain lookup hashes. Resolve draining reads from the frozen manifest rather than parsing the tombstone. Do not store plaintext bearer. + +**Suggested commit:** `feat(access): drain final deletion credentials` + +### Task 9: Implement and supervise the deletion worker + +**Objective:** Execute external phases retryably without overlapping destructive actions or breaking shutdown. + +**Files:** +- Create: `locks-server/src/deletion_worker.rs` +- Modify: `locks-server/src/main.rs` +- Modify: `locks-server/src/config/schema.rs` +- Modify: `locks-server/src/config/defaults.rs` +- Modify: `locks-server/src/config/validation.rs` +- Modify: `locks-server/src/app_state/readiness.rs` +- Modify: `locks-server/src/api/runtime.rs` +- Test: worker unit tests and `locks-e2e/tests/postgres_runtime.rs` + +**RED:** Crash/reclaim tests after every external side effect; advisory ownership exclusion; tombstone read-back/replacement failure; retry exhaustion/resume; force escalation; content-first/tombstone-last; missing tombstone allowed only after durable final-removal phase; readiness degradation; shutdown stops claims and bounds worker join. + +**GREEN:** Reuse existing worker configuration conventions but keep queue cadence and retry due time separate. Never log manifest, resource paths, Bundle IDs, credentials, readers, or Paykit payloads. + +**Suggested commit:** `feat(server): run graceful deletion worker` + +### Task 10: Purge graceful state and preserve force blocks + +**Objective:** Complete graceful forget/republication without reactivating old authority, while permanently blocking force-deleted Lock IDs. + +**Files:** +- Create/modify: lock-scoped purge repository/use case in `locks-service/src/` +- Modify: deletion worker +- Modify: content-lock creation ownership/force-receipt checks +- Test: PostgreSQL E2E and creator publishing HTTP E2E + +**RED:** Prove all Locks task/proof/entitlement/credential/job rows are gone after graceful completion, ownership is released only after external cleanup, fresh same-ID publication accepts only new Bundle IDs, late old task replay cannot reactivate, force receipt blocks same-ID publication forever, and failed force paths retain ownership. + +**Suggested commit:** `feat(service): finalize lock deletion lifecycle` + +### Task 11: Reader UX and documentation + +**Objective:** Make the application deadline visible and prevent accidental late manual payment. + +**Files:** +- Modify only currently active reader/demo files discovered at implementation time; audit `examples/js-sdk/`, `README.md`, and `docs/LOCAL_OPERATOR_DEMO.md` before naming exact files. +- Modify: protocol/API documentation for criterion and deletion routes. + +**RED:** Browser/demo test with injected clock proves payment action disabled at equality boundary only after the inclusive deadline has passed, warning is visible, and no automatic payment is initiated. + +**GREEN:** Display Paykit-returned absolute deadline; do not derive from browser clock plus duration. + +**Suggested commit:** `docs: document payment deadlines and lock deletion` + +## Cross-repository implementation/review order + +1. Commit synchronized plan-only changes separately in Locks and Paykit Server. +2. Locks Task 1 (`payment_in`) and publish/review the exact Locks Core revision Paykit will consume. +3. Paykit Server invoice persistence/response and deadline observation slices. +4. Resolve and patch the exact per-Bundle enums and operational-drain cleanup route in both plans. +5. Paykit Server drain/status API slices. +6. Locks invoice persistence and payment-drain client slices. +7. Locks deletion persistence/API/worker/credential slices. +8. Cross-service E2E and docs. + +No repository may claim the sibling contract implemented until pinned dependency/revision and live tests prove it. + +## Verification + +Repository-local final verification: + +```bash +cargo fmt --all +cargo test -p locks-core +cargo test -p locks-service +cargo test -p locks-server +cargo test -p locks-sdk +cargo test -p locks-sdk-wasm +TEST_DATABASE_URL="$TEST_DATABASE_URL" cargo test -p locks-e2e --test postgres_runtime +TEST_DATABASE_URL="$TEST_DATABASE_URL" cargo test -p locks-e2e --test production_creator_publishing_http +TEST_DATABASE_URL="$TEST_DATABASE_URL" cargo test -p locks-e2e --test retrieval_access_http +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +cargo fmt --all --check +git diff --check +``` + +Cross-service acceptance must additionally prove: + +- invoice timestamp exact replay; +- canonical lock/request `payment_in` mismatch rejection with no side effects; +- inclusive first amount-matched-observation deadline; +- underpayment expiry and matched-payment confirmation continuation; +- atomic acceptance/cancellation drain cutoff; +- cancellation enqueue without delivery wait; +- Locks-only minimum-confirmation decision; +- deletion crash recovery after every remote effect; +- exact tombstone replacement halt/resume; +- existing/final credential drain and concurrent per-path consumption; +- graceful same-ID republication with no old authorization revival; +- permanent force same-ID block. + +## Remaining implementation-contract gates + +These do not reopen accepted product semantics, but code must not start for the affected slice until both plans are patched identically: + +1. Exact `request_state` and `payment_state` wire enum values and mappings for Paykit recovery/conflict conditions. +2. Exact aggregate drain response fields/status values. +3. Exact signed route/body for deleting completed Paykit operational drain state. +4. Exact Locks stable `failure_code` vocabulary. +5. Exact configuration keys for retry attempts/backoff and final credential windows, within the accepted defaults/maxima. + +## Out of scope + +- Paykit protocol `payment_due_at` field or accepted-expiry event. +- Automatic refunds or late-payment access. +- Manual/automatic Bitcoin payment from the reader. +- Cross-system transactions or exactly-once external effects. +- Historical production-data migration/backfill. +- Republishing force-deleted Lock IDs. +- Deleting reader-downloaded copies. diff --git a/examples/js-sdk/README.md b/examples/js-sdk/README.md index 99d36ec..b50e9dd 100644 --- a/examples/js-sdk/README.md +++ b/examples/js-sdk/README.md @@ -1,6 +1,6 @@ # JS SDK local testnet creator/reader demos -These examples are a script-driven local Pubky testnet workflow plus browser UIs for the Locks JS/WASM SDK creator and unauthenticated reader paths. +These examples are a script-driven local Pubky testnet workflow plus browser UIs for the Locks JS/WASM SDK creator and reader paths. The reader browser remains unauthenticated; `paykit-payment` additionally uses a fixed `content-viewer` identity only inside the native Paykit reader helper. The creator demo publishes locked content and displays a **Viewer content lock resource**. It has separate controls for selecting the primary file and optional secondary files. The primary file becomes the default resource readers usually open first; each secondary file is uploaded as an additional resource in the same content lock. Copy the viewer resource into the separate reader demo to exercise the unauthenticated reader flow. @@ -15,8 +15,14 @@ examples/js-sdk/reader.html examples/js-sdk/reader-app.js examples/js-sdk/reader-flow.js examples/js-sdk/scripts/init-config.mjs +examples/js-sdk/scripts/homegate-bridge.mjs examples/js-sdk/scripts/create-user.mjs examples/js-sdk/scripts/authenticate.mjs +examples/js-sdk/scripts/prepare-paykit-reader.mjs +examples/js-sdk/scripts/receive-paykit-request.mjs +examples/js-sdk/scripts/register-paykit-reader.mjs +examples/js-sdk/scripts/lib/paykit-reader-worker.mjs +examples/js-sdk/scripts/test-paykit-reader-worker.mjs examples/js-sdk/scripts/start-demo-server.mjs examples/js-sdk/scripts/start-reader-demo-server.mjs ``` @@ -24,7 +30,7 @@ examples/js-sdk/scripts/start-reader-demo-server.mjs Generated local state lives under: ```text -./.local/js-sdk-demo/config.json +./.local/demo-config/config.json ./.local/js-sdk-demo/content-creator-session.json ./.local/lock-server/passphrase ./.local/lock-server/recovery_file @@ -32,6 +38,13 @@ Generated local state lives under: ./.local/content-creator/passphrase ./.local/content-creator/recovery_file ./.local/content-creator/profile.json +./.local/content-viewer/passphrase +./.local/content-viewer/recovery_file +./.local/content-viewer/profile.json +./.local/paykit-reader/state.v1 +./.local/paykit-reader/prepared.v1.json +./.local/paykit-reader/worker.v1.json +./.local/paykit-reader/owner.lock ``` ## Prerequisites @@ -49,7 +62,7 @@ Required tools/services: DHT bootstrap localhost:6881 ``` -Build the local WASM SDK package first: +For direct npm development, build the local WASM SDK package first: ```bash npm --prefix locks-sdk/bindings/js run build @@ -69,14 +82,13 @@ The examples package uses `@synonymdev/pubky` for Node-side Pubky testnet auth/k ## Local environment setup -The demos need four processes/services alive at the same time: +The supported end-to-end path is the complete local Compose stack documented below. It generates ignored owner-only credentials, starts both databases and both application servers, bootstraps Bitcoin regtest, waits for Fulcrum using `server.version`, and starts the creator and reader demos. -1. local Pubky testnet -2. Postgres -3. Lock Server on `127.0.0.1:3000` -4. creator demo server on `localhost:8080` and/or reader demo server on `localhost:8081` +The Compose image builds the JS/WASM package itself. A fresh checkout does not need a host-generated `locks-sdk/bindings/js/pkg` directory. -### Docker Compose local stack +For direct npm development without Compose, provide a running local Pubky testnet, PostgreSQL, Lock Server, and Paykit Server first. `init-config` reads the Lock Server public key from `~/.pubky-lock/config.toml` by default; it does not read the Lock Server signing secret. + +### Basic Locks stack For a containerized local stack from the repository root: @@ -116,161 +128,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, a local Homegate-compatible signup bridge, Locks, Paykit Server, and both browser demos. All published ports bind to host loopback. Paykit is browser-visible at `http://localhost:3001`, the Homegate bridge at `http://localhost:6288`, and Fulcrum at `tcp://localhost:60001`. Locks reaches Paykit at `http://127.0.0.1:3001` inside the shared Pubky network namespace. The unprivileged creator and reader images contain the reviewed native helpers and a package built in the image; they receive only their explicit runtime directories, never the repository root or Lock Server identity volume. -These values are written to: +Open: ```text -./.local/js-sdk-demo/config.json -``` - -## Setup - -Initialize JS demo config: - -```bash -npm --prefix examples/js-sdk run init-config +Creator: http://localhost:8080/examples/js-sdk/ +Reader: http://localhost:8088/reader/ +Paykit: http://localhost:3001/setup ``` -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. +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. -Existing keypairs are reused. To regenerate one role: - -```bash -npm --prefix examples/js-sdk run create-user -- --role content-creator --force -``` - -## Run the demo server +### Direct npm server ```bash npm --prefix examples/js-sdk run start-server @@ -327,7 +268,9 @@ POST /api/demo-auth/start GET /api/demo-auth/status ``` -It displays a `pubkyauth://...` string and command like: +It displays a `pubkyauth://...` string. In the Compose external-wallet flow, scan or paste that request into the wallet under test. The approved wallet identity becomes the canonical creator identity and is published for the reader and Paykit services; the demo never imports the wallet private key. + +For direct npm development outside the Compose external-wallet mode, the recovery-file command remains available: ```bash npm --prefix examples/js-sdk run authenticate -- \ @@ -341,7 +284,7 @@ npm --prefix examples/js-sdk run authenticate -- \ npm --prefix examples/js-sdk run authenticate -- --role content-creator ``` -It signs up/registers the `content-creator` with the configured homeserver, approves the auth string, and the demo server persists its session to: +That command signs up/registers the local `content-creator`, approves the auth string, and the demo server persists its session to: ```text ./.local/js-sdk-demo/content-creator-session.json @@ -351,15 +294,15 @@ It signs up/registers the `content-creator` with the configured homeserver, appr Click **Authenticate to Lock Server**. -The browser uses the Locks JS/WASM SDK to redirect to the Lock-Server-hosted `/connect` shell. The raw legacy-connect authorization URL stays on the Lock Server origin. +Both creator pages open the Lock Server `/connect` shell in an iframe modal. The raw legacy-connect authorization URL stays on the Lock Server origin. -The callback URL is: +The shell returns `{ state, code }` directly to the parent with `postMessage`. The parent accepts the result only from the exact Lock Server origin and iframe window, then validates the state before exchanging the one-time code. The configured callback URL supplies the parent target origin; the browser does not navigate to it: ```text http://localhost:8080/auth/lock-server/callback ``` -Approve the Lock Server auth string with the same role: +Approve the Lock Server auth string with the same identity. In Compose, scan or paste it into the same external wallet. For direct npm development, use: ```bash npm --prefix examples/js-sdk run authenticate -- \ @@ -367,7 +310,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 +327,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. In the external-wallet flow, scan or paste the Paykit authorization request into the same wallet. For direct npm development with a generated creator recovery file, the companion-auth wrapper remains available: + +```bash +docker compose -f compose.paykit-local-demo.yaml exec creator-demo npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator +``` + +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 +367,11 @@ After success, the page displays the **Viewer content lock resource**: ## Reader browser flow -The reader demo is unauthenticated for now. It does not create or use a Pubky reader identity. +The browser remains unauthenticated. A `paykit-payment` proof carries the public key prepared by the native helper; the browser never receives the reader secret or encrypted Paykit state. 1. Copy the creator demo's **Viewer content lock resource** and paste it into the reader demo. 2. Click **Load lock**. The browser SDK validates the content lock and resolves the Lock Server. -3. Choose `dev-static` proof control: +3. The loaded lock selects its verifier mode. For `dev-static`, choose: ```text satisfied = true | false ``` @@ -418,7 +380,21 @@ The reader demo is unauthenticated for now. It does not create or use a Pubky re 6. Click **Issue access credential**. 7. Click **Read guarded content**. -The reader page persists local progress in browser `localStorage` under `pubky-locks-reader-demo.*` and has a visible **Reset reader state** button. Bundle IDs and access credentials are bearer-like local-dev secrets; the demo displays them for debugging only. +For `paykit-payment`: + +1. The in-process Paykit reader worker starts with `reader-demo`, creates or restores the durable encrypted reader state, publishes and reads back its Receiver Marker, and waits for private Paykit messages. The page polls its closed status automatically; proof submission remains disabled until the worker is prepared and its Reader Pubky matches the current `content-viewer` identity. +2. Click **Submit proof bundle**. The browser submits one `paykit-payment` proof with the confirmed top-level `reader_public_key` and an empty `{}` criterion payload. It never calls the dev completion route. +3. The worker advances the Paykit/Noise link and receives the real Payment Request without a foreground command. The page displays only its validated request ID, regtest address, amount in sats, canonical manual `bitcoin-cli` payment command, and optional mining command. +4. Run the displayed payment command in a terminal. Mining is optional because local Locks uses `minimum_confirmations = 0`. +5. The page polls `pending` and `in_progress` lifecycle states. On `completed`, it issues an access credential and reads the primary guarded resource. `failed`, `expired`, and unknown states fail closed. Use **Resume payment verification polling** after a reload. + +The worker is the sole mutable owner of `./.local/paykit-reader/state.v1`. A direct child holds a kernel advisory lock on the owner-only `./.local/paykit-reader/owner.lock` file for the worker lifetime; the child exits when the parent's stdin closes, so the kernel releases ownership after normal exit or a crash without stale-lock takeover. The legacy `prepare-paykit-reader` and `receive-paykit-request` wrappers reject execution while the embedded worker is enabled and acquire the same lock when run standalone. + +`GET /api/health` reports HTTP-process liveness only. `GET /api/paykit-reader/status` reports the separate worker readiness/projection contract; Compose uses that second endpoint for health so a serving but unprepared or failed reader is not considered ready. + +The native helper is `/usr/local/bin/paykit-reader-demo`; `PAYKIT_READER_DEMO_BIN` is a test-only executable override. Its state path and local Pubky endpoints come from the `PAYKIT_READER_*` Compose environment. Reader homeserver registration runs in a separate direct-spawned Node subprocess with bounded output, timeout, and TERM→KILL cancellation because the Pubky JS API does not expose request cancellation; cancellation waits for child settlement before ownership is released. The worker derives the Paykit peer from the public `content-creator` profile, then passes only the closed native helper environment. The state path must end in `.local/paykit-reader/state.v1`. The helper owns encrypted versioned state, owner-only file permissions, fresh-nonce rewrites, and invariant validation. The worker fences status publication and state checkpoints on current kernel-lock ownership, atomically writes its separate owner-only `worker.v1.json` projection, and clears in-memory readiness immediately if ownership is lost. The HTTP server validates the projection again and requires current in-memory ownership before returning a ready browser status. Terminal worker failure closes PID 1 after a coarse error so Compose restart policy applies. + +The reader page persists local progress in browser `localStorage` under `pubky-locks-reader-demo.*` and has a visible **Reset reader state** button. Retrieved guarded bytes are never persisted: text and JSON render as text, images use a temporary object URL, and other binary content exposes metadata and a temporary download link. Bundle IDs and access credentials are bearer-like local-dev secrets; the demo displays them for debugging only. ## Static drift check @@ -432,8 +408,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..daf5f04 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); } @@ -204,7 +329,7 @@ el.startDemoAuth.addEventListener('click', async () => { await refreshDemoAuthStatus(); return; } - el.demoAuthCommand.textContent = `${result.authorizationUrl}\n\n${result.command}`; + el.demoAuthCommand.textContent = [result.authorizationUrl, result.command].filter(Boolean).join('\n\n'); } catch (error) { showError(el.demoAuthStatus, error); } @@ -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

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

Secondary files

Waiting for access credential.

Content

-

+      
diff --git a/examples/js-sdk/scripts/authenticate-paykit.mjs b/examples/js-sdk/scripts/authenticate-paykit.mjs new file mode 100644 index 0000000..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/homegate-bridge.mjs b/examples/js-sdk/scripts/homegate-bridge.mjs new file mode 100644 index 0000000..fee6867 --- /dev/null +++ b/examples/js-sdk/scripts/homegate-bridge.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; + +const listenPort = Number(process.env.HOMEGATE_BRIDGE_PORT ?? 8082); +const configPath = process.env.HOMEGATE_BRIDGE_CONFIG; +const homeserverAdminUrl = process.env.HOMEGATE_BRIDGE_HOMESERVER_ADMIN_URL; +const homeserverAdminPassword = process.env.PUBKY_HOMESERVER_ADMIN_PASSWORD; + +if (!configPath || !homeserverAdminUrl || !homeserverAdminPassword) { + throw new Error('Homegate bridge configuration is incomplete'); +} + +const server = createServer(async (request, response) => { + if (request.method === 'GET' && request.url === '/health') { + return sendJson(response, { ok: true }); + } + if (request.method !== 'POST' || request.url !== '/ip_verification') { + response.writeHead(404).end('not found'); + return; + } + + try { + const [{ testnet }, signupResponse] = await Promise.all([ + readDemoConfig(configPath), + fetch(`${homeserverAdminUrl}/generate_signup_token`, { + headers: { 'x-admin-password': homeserverAdminPassword }, + }), + ]); + if (!signupResponse.ok) throw new Error(`homeserver returned HTTP ${signupResponse.status}`); + const signupCode = (await signupResponse.text()).trim(); + if (!signupCode || typeof testnet?.homeserver !== 'string') { + throw new Error('homeserver signup response is incomplete'); + } + sendJson(response, { signupCode, homeserverPubky: testnet.homeserver }); + } catch { + sendJson(response, { error: 'signup unavailable' }, 503); + } +}); + +server.listen(listenPort, '0.0.0.0', () => { + console.log(`Homegate bridge listening on port ${listenPort}`); +}); + +async function readDemoConfig(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +function sendJson(response, value, status = 200) { + response.writeHead(status, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + }); + response.end(`${JSON.stringify(value)}\n`); +} diff --git a/examples/js-sdk/scripts/init-config.mjs b/examples/js-sdk/scripts/init-config.mjs index 2c2655a..dc709b7 100644 --- a/examples/js-sdk/scripts/init-config.mjs +++ b/examples/js-sdk/scripts/init-config.mjs @@ -2,14 +2,15 @@ import { demoConfigPath, parseArgs, writeJson } from './lib/paths.mjs'; import { buildDefaultDemoConfig } from './lib/config.mjs'; -// Defaults: read lock_server_public_key from ~/.pubky-lock/config.toml and write ./.local/js-sdk-demo/config.json. -// Local testnet defaults: http://localhost:15411, http://localhost:15412, localhost:6881. +// Defaults: read lock_server_public_key from ~/.pubky-lock/config.toml and write ./.local/demo-config/config.json. +// Local testnet defaults: http://127.0.0.1:15411, http://127.0.0.1:15412, 127.0.0.1:6881. const args = parseArgs(); const output = typeof args.output === 'string' ? args.output : demoConfigPath; try { - const config = await buildDefaultDemoConfig(); + const lockConfigPath = typeof args['lock-config'] === 'string' ? args['lock-config'] : undefined; + const config = await buildDefaultDemoConfig(lockConfigPath); await writeJson(output, config); console.log(JSON.stringify({ ok: true, config: output, lockServer: config.lockServer }, null, 2)); } catch (error) { diff --git a/examples/js-sdk/scripts/init-paykit-compose.mjs b/examples/js-sdk/scripts/init-paykit-compose.mjs new file mode 100644 index 0000000..615923e --- /dev/null +++ b/examples/js-sdk/scripts/init-paykit-compose.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +import { randomBytes } from 'node:crypto'; +import { chmod, mkdir } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + buildPaykitServerConfig, + buildPubkyHomeserverComposeConfig, + readLockServerPublicKey, + validatePaykitComposeEnvironment, +} from './lib/config.mjs'; +import { localPath, parseArgs, readPrivateText, writeAtomicFile } from './lib/paths.mjs'; + +const SECRET_KEYS = [ + 'version', + 'locksPostgresPassword', + 'paykitPostgresPassword', + 'paykitMasterKey', + 'bitcoinRpcUser', + 'bitcoinRpcPassword', + 'locksCreatorAuthKey', + 'pubkyHomeserverAdminPassword', +]; +const TOKEN = /^[A-Za-z0-9_-]{16,128}$/; +const MASTER_KEY = /^[A-Za-z0-9_-]{43}$/; + +function randomToken(bytes = 24) { + return randomBytes(bytes).toString('base64url'); +} + +export function createComposeSecrets() { + return Object.freeze({ + version: 1, + locksPostgresPassword: randomToken(), + paykitPostgresPassword: randomToken(), + paykitMasterKey: randomToken(32), + bitcoinRpcUser: `bitcoin_${randomToken(12)}`, + bitcoinRpcPassword: randomToken(32), + locksCreatorAuthKey: randomToken(32), + pubkyHomeserverAdminPassword: randomToken(24), + }); +} + +export function validateComposeSecrets(value) { + if ( + value === null + || typeof value !== 'object' + || Array.isArray(value) + || Object.keys(value).length !== SECRET_KEYS.length + || !SECRET_KEYS.every((key) => Object.hasOwn(value, key)) + || value.version !== 1 + ) { + throw new Error('invalid persisted Compose secrets'); + } + for (const key of [ + 'locksPostgresPassword', + 'paykitPostgresPassword', + 'bitcoinRpcUser', + 'bitcoinRpcPassword', + 'pubkyHomeserverAdminPassword', + ]) { + if (typeof value[key] !== 'string' || !TOKEN.test(value[key])) { + throw new Error('invalid persisted Compose secrets'); + } + } + for (const key of ['paykitMasterKey', 'locksCreatorAuthKey']) { + if (typeof value[key] !== 'string' || !MASTER_KEY.test(value[key])) { + throw new Error('invalid persisted Compose secrets'); + } + } + return Object.freeze({ ...value }); +} + +async function writeSecure(path, content, mode = 0o600) { + await writeAtomicFile(path, content, mode); +} + +function generatedPaths(root) { + return { + secrets: join(root, 'compose-secrets.json'), + locksPostgres: join(root, 'locks-postgres', 'locks-postgres.env'), + locksServer: join(root, 'locks-server', 'compose.env'), + paykitPostgres: join(root, 'paykit-postgres', 'postgres.env'), + paykitServer: join(root, 'paykit-server', 'paykit.env'), + paykitConfig: join(root, 'paykit-config', 'config.toml'), + bitcoinRpc: join(root, 'bitcoin-rpc', 'bitcoin-rpc.env'), + pubkyHomeserver: join(root, 'pubky-homeserver', 'config.toml'), + homegateBridge: join(root, 'homegate-bridge', 'homegate.env'), + }; +} + +export async function initializePaykitCompose({ + root = localPath, + lockConfigPath, + configOnly = false, +} = {}) { + const paths = generatedPaths(root); + if (configOnly) { + if (!lockConfigPath) throw new Error('--lock-config is required with --config-only'); + const lockServerPubky = await readLockServerPublicKey(lockConfigPath); + await writeSecure(paths.paykitConfig, buildPaykitServerConfig({ lockServerPubky }), 0o644); + return Object.freeze({ root: resolve(root), configGenerated: true }); + } + + await Promise.all([ + 'js-sdk-demo', + 'demo-config', + 'creator-public', + 'bitcoin-bootstrap', + 'content-creator', + 'content-viewer', + 'paykit-reader', + 'locks-postgres', + 'paykit-postgres', + 'bitcoin-rpc', + 'pubky-homeserver', + 'paykit-server', + 'paykit-config', + 'homegate-bridge', + ].map(async (directory) => { + const path = join(root, directory); + await mkdir(path, { recursive: true, mode: 0o700 }); + await chmod(path, 0o700); + })); + + let secrets; + try { + secrets = validateComposeSecrets(JSON.parse(await readPrivateText(paths.secrets))); + } catch (error) { + if (error?.code !== 'ENOENT') throw new Error('persisted Compose secrets are invalid'); + secrets = createComposeSecrets(); + await writeSecure(paths.secrets, `${JSON.stringify(secrets, null, 2)}\n`); + } + + const paykitDatabaseUrl = `postgres://paykit:${secrets.paykitPostgresPassword}@paykit-postgres:5432/paykit`; + validatePaykitComposeEnvironment({ + PAYKIT_DATABASE_URL: paykitDatabaseUrl, + PAYKIT_MASTER_KEY: secrets.paykitMasterKey, + BITCOIN_RPC_USER: secrets.bitcoinRpcUser, + BITCOIN_RPC_PASSWORD: secrets.bitcoinRpcPassword, + }); + + await Promise.all([ + writeSecure(paths.locksPostgres, `POSTGRES_DB=locks_test\nPOSTGRES_USER=locks\nPOSTGRES_PASSWORD=${secrets.locksPostgresPassword}\n`), + writeSecure(paths.locksServer, `PUBKY_LOCK_DATABASE_URL=postgres://locks:${secrets.locksPostgresPassword}@postgres:5432/locks_test\nPUBKY_LOCK_CREATOR_AUTH_ENCRYPTION_KEY=${secrets.locksCreatorAuthKey}\n`), + writeSecure(paths.paykitPostgres, `POSTGRES_DB=paykit\nPOSTGRES_USER=paykit\nPOSTGRES_PASSWORD=${secrets.paykitPostgresPassword}\n`), + writeSecure(paths.paykitServer, `PAYKIT_DATABASE_URL=${paykitDatabaseUrl}\nPAYKIT_MASTER_KEY=${secrets.paykitMasterKey}\n`), + writeSecure(paths.bitcoinRpc, `BITCOIN_RPC_USER=${secrets.bitcoinRpcUser}\nBITCOIN_RPC_PASSWORD=${secrets.bitcoinRpcPassword}\nRPCUSER=${secrets.bitcoinRpcUser}\nRPCPASSWORD=${secrets.bitcoinRpcPassword}\n`), + writeSecure(paths.pubkyHomeserver, buildPubkyHomeserverComposeConfig({ + databasePassword: secrets.locksPostgresPassword, + adminPassword: secrets.pubkyHomeserverAdminPassword, + })), + writeSecure(paths.homegateBridge, `PUBKY_HOMESERVER_ADMIN_PASSWORD=${secrets.pubkyHomeserverAdminPassword}\n`), + ]); + + if (lockConfigPath) { + const lockServerPubky = await readLockServerPublicKey(lockConfigPath); + await writeSecure(paths.paykitConfig, buildPaykitServerConfig({ lockServerPubky }), 0o644); + } + + return Object.freeze({ root: resolve(root), configGenerated: Boolean(lockConfigPath) }); +} + +export async function main(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + const result = await initializePaykitCompose({ + root: typeof args['local-dir'] === 'string' ? resolve(args['local-dir']) : localPath, + lockConfigPath: typeof args['lock-config'] === 'string' ? args['lock-config'] : undefined, + configOnly: args['config-only'] === true, + }); + console.log(JSON.stringify({ ok: true, localDir: result.root, configGenerated: result.configGenerated })); +} + +const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + main().catch((error) => { + console.error(`init-paykit-compose failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/examples/js-sdk/scripts/lib/config.mjs b/examples/js-sdk/scripts/lib/config.mjs index e5c247c..e3f1eeb 100644 --- a/examples/js-sdk/scripts/lib/config.mjs +++ b/examples/js-sdk/scripts/lib/config.mjs @@ -9,18 +9,21 @@ export const defaultLockServerConfigPath = '~/.pubky-lock/config.toml'; export const defaultDemoConfig = { demoServer: { - url: 'http://localhost:8080', + url: 'http://127.0.0.1:8080', }, lockServer: { url: 'http://127.0.0.1:3000', pubky: '', configPath: defaultLockServerConfigPath, }, + paykit: { + url: 'http://127.0.0.1:3001', + }, testnet: { homeserver: 'pubky8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo', - httpRelay: 'http://localhost:15412', - pkarrRelay: 'http://localhost:15411', - dhtBootstrap: 'localhost:6881', + httpRelay: 'http://127.0.0.1:15412', + pkarrRelay: 'http://127.0.0.1:15411', + dhtBootstrap: '127.0.0.1:6881', }, }; @@ -65,6 +68,7 @@ export function validateDemoConfig(config) { ['lockServer', 'url'], ['lockServer', 'pubky'], ['lockServer', 'configPath'], + ['paykit', 'url'], ['testnet', 'homeserver'], ['testnet', 'httpRelay'], ['testnet', 'pkarrRelay'], @@ -78,9 +82,19 @@ export function validateDemoConfig(config) { new URL(config.demoServer.url); new URL(config.lockServer.url); + const paykitUrl = new URL(config.paykit.url); new URL(config.testnet.httpRelay); new URL(config.testnet.pkarrRelay); + if ( + !['http:', 'https:'].includes(paykitUrl.protocol) + || paykitUrl.username + || paykitUrl.password + || config.paykit.url !== paykitUrl.origin + ) { + throw new Error('invalid demo config: paykit.url must be an exact HTTP(S) origin without credentials'); + } + if (!config.lockServer.pubky.startsWith('pubky')) { throw new Error('invalid demo config: lockServer.pubky must start with pubky'); } @@ -123,8 +137,105 @@ export function withInternalServiceUrls(config, env = process.env) { return validateDemoConfig(internal); } -export async function buildDefaultDemoConfig() { +export async function buildDefaultDemoConfig(lockServerConfigPath = defaultLockServerConfigPath) { const config = structuredClone(defaultDemoConfig); - config.lockServer.pubky = await readLockServerPublicKey(config.lockServer.configPath); + config.lockServer.configPath = lockServerConfigPath; + config.lockServer.pubky = await readLockServerPublicKey(lockServerConfigPath); return validateDemoConfig(config); } + +export function buildPaykitServerConfig({ lockServerPubky }) { + if (!/^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(lockServerPubky ?? '')) { + throw new Error('invalid Lock Server Pubky'); + } + return `[http] +listen_addr = "0.0.0.0:3001" + +[locks] +trusted_public_key = "${lockServerPubky}" + +[setup] +allowed_origins = ["http://127.0.0.1:8080", "http://localhost:8080"] + +[paykit] +receiver_path = "bitkit/server" +receiver_path_priority = ["bitkit"] +network = "testnet" + +[bitcoin] +network = "regtest" + +[electrum] +endpoint = "tcp://fulcrum:50001" +poll_interval = "1s" +request_timeout = "10s" +connect_retries = 1 + +[outbox] +poll_interval = "500ms" +batch_size = 16 +lease_duration = "30s" +retry_initial = "1s" +retry_max = "5m" +`; +} + +export function validatePaykitComposeEnvironment(environment) { + const databaseUrl = environment.PAYKIT_DATABASE_URL; + const masterKey = environment.PAYKIT_MASTER_KEY; + const bitcoinRpcUser = environment.BITCOIN_RPC_USER; + const bitcoinRpcPassword = environment.BITCOIN_RPC_PASSWORD; + if (typeof databaseUrl !== 'string' || !/^postgres:\/\/[^\s]+$/.test(databaseUrl)) { + throw new Error('PAYKIT_DATABASE_URL is invalid'); + } + if (typeof masterKey !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(masterKey)) { + throw new Error('PAYKIT_MASTER_KEY is invalid'); + } + if (typeof bitcoinRpcUser !== 'string' || !/^[A-Za-z0-9_-]{8,128}$/.test(bitcoinRpcUser)) { + throw new Error('BITCOIN_RPC_USER is invalid'); + } + if (typeof bitcoinRpcPassword !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(bitcoinRpcPassword)) { + throw new Error('BITCOIN_RPC_PASSWORD is invalid'); + } + return { databaseUrl, masterKey, bitcoinRpcUser, bitcoinRpcPassword }; +} + +export function buildPubkyHomeserverComposeConfig({ databasePassword, adminPassword }) { + for (const [name, value] of Object.entries({ databasePassword, adminPassword })) { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(value)) { + throw new Error(`invalid Pubky homeserver ${name}`); + } + } + return `[general] +database_url = "postgres://locks:${databasePassword}@postgres:5432/pubky_homeserver" +signup_mode = "open" + +[drive] +pubky_listen_socket = "0.0.0.0:6287" +icann_listen_socket = "0.0.0.0:6286" + +[storage] +type = "file_system" + +[admin] +enabled = true +listen_socket = "0.0.0.0:6288" +admin_password = "${adminPassword}" + +[metrics] +enabled = false +listen_socket = "0.0.0.0:6289" + +[pkdns] +public_ip = "127.0.0.1" +public_pubky_tls_port = 6287 +public_icann_http_port = 6286 +icann_domain = "localhost" +user_keys_republisher_interval = 14400 +dht_request_timeout_ms = 2000 + +[logging] +level = "info" +module_levels = ["pubky_homeserver=debug", "tower_http=debug"] +`; +} diff --git a/examples/js-sdk/scripts/lib/creator-session-state.mjs b/examples/js-sdk/scripts/lib/creator-session-state.mjs new file mode 100644 index 0000000..d982fc5 --- /dev/null +++ b/examples/js-sdk/scripts/lib/creator-session-state.mjs @@ -0,0 +1,70 @@ +import { chmod, rm } from 'node:fs/promises'; + +import { + contentCreatorSessionPath, + readJson, + roleProfilePath, + writeJson, +} from './paths.mjs'; + +const defaultProfilePath = roleProfilePath('content-creator'); + +export async function clearCreatorDemoSession(path = contentCreatorSessionPath) { + await rm(path, { force: true }); +} + +export async function readCreatorDemoSessionForCurrentRole({ + sessionPath = contentCreatorSessionPath, + profilePath = defaultProfilePath, +} = {}) { + let session; + try { + session = await readJson(sessionPath); + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } + + const profile = profilePath ? await readJson(profilePath) : null; + if (!validCreatorSession(session) || (profile && !creatorIdentitiesMatch(session, profile))) { + await clearCreatorDemoSession(sessionPath); + return null; + } + + await chmod(sessionPath, 0o600); + return session; +} + +export async function writeCreatorDemoSessionForCurrentRole( + session, + { + sessionPath = contentCreatorSessionPath, + profilePath = defaultProfilePath, + } = {}, +) { + const profileBeforeWrite = profilePath ? await readJson(profilePath) : null; + if (!validCreatorSession(session) || (profileBeforeWrite && !creatorIdentitiesMatch(session, profileBeforeWrite))) { + await clearCreatorDemoSession(sessionPath); + throw new Error('creator identity changed during demo authentication'); + } + + await writeJson(sessionPath, session, { mode: 0o600 }); + await chmod(sessionPath, 0o600); + + const profileAfterWrite = profilePath ? await readJson(profilePath) : null; + if (profileAfterWrite && !creatorIdentitiesMatch(session, profileAfterWrite)) { + await clearCreatorDemoSession(sessionPath); + throw new Error('creator identity changed during demo authentication'); + } +} + +function creatorIdentitiesMatch(session, profile) { + return validCreatorSession(session) + && profile?.role === 'content-creator' + && session.pubky === profile.pubky; +} + +function validCreatorSession(session) { + return session?.role === 'content-creator' + && /^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(session.pubky ?? ''); +} diff --git a/examples/js-sdk/scripts/lib/creator-static-path.mjs b/examples/js-sdk/scripts/lib/creator-static-path.mjs new file mode 100644 index 0000000..e47fb27 --- /dev/null +++ b/examples/js-sdk/scripts/lib/creator-static-path.mjs @@ -0,0 +1,36 @@ +import { realpathSync } from 'node:fs'; +import { normalize, resolve, sep } from 'node:path'; + +export function resolveCreatorStaticPath(pathname, { repoRoot, examplesRoot }) { + let relative; + if (pathname === '/') { + relative = 'index.html'; + } else if (pathname.startsWith('/examples/js-sdk/')) { + relative = pathname.slice('/examples/js-sdk/'.length); + if (relative.length === 0) relative = 'index.html'; + } else if (pathname.startsWith('/pkg/')) { + const packagePath = pathname.slice('/pkg/'.length); + return resolveExistingPathWithin(resolve(repoRoot, 'locks-sdk/bindings/js/pkg'), packagePath); + } else if (pathname.startsWith('/locks-sdk/bindings/js/pkg/')) { + const packagePath = pathname.slice('/locks-sdk/bindings/js/pkg/'.length); + return resolveExistingPathWithin(resolve(repoRoot, 'locks-sdk/bindings/js/pkg'), packagePath); + } else { + return null; + } + return resolveExistingPathWithin(examplesRoot, relative); +} + +export function resolveExistingPathWithin(root, relative) { + const normalized = normalize(relative).replace(/^[/\\]+/, ''); + const absoluteRoot = resolve(root); + const candidate = resolve(absoluteRoot, normalized); + if (candidate !== absoluteRoot && !candidate.startsWith(`${absoluteRoot}${sep}`)) return null; + try { + const canonicalRoot = realpathSync(absoluteRoot); + const canonicalCandidate = realpathSync(candidate); + if (canonicalCandidate !== canonicalRoot && !canonicalCandidate.startsWith(`${canonicalRoot}${sep}`)) return null; + return canonicalCandidate; + } catch { + return null; + } +} diff --git a/examples/js-sdk/scripts/lib/paths.mjs b/examples/js-sdk/scripts/lib/paths.mjs index 5292072..3efdd32 100644 --- a/examples/js-sdk/scripts/lib/paths.mjs +++ b/examples/js-sdk/scripts/lib/paths.mjs @@ -1,14 +1,33 @@ +import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname, join, resolve } from 'node:path'; +import { chmod, lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; export const repoRoot = resolve(fileURLToPath(new URL('../../../../', import.meta.url))); export const examplesRoot = join(repoRoot, 'examples', 'js-sdk'); export const localPath = join(repoRoot, '.local'); export const demoStateDir = join(localPath, 'js-sdk-demo'); -export const demoConfigPath = join(demoStateDir, 'config.json'); +export const demoPublicConfigDir = join(localPath, 'demo-config'); +export const demoConfigPath = join(demoPublicConfigDir, 'config.json'); export const contentCreatorSessionPath = join(demoStateDir, 'content-creator-session.json'); +export const creatorPublicDir = join(localPath, 'creator-public'); +export const creatorPublicProfilePath = join(creatorPublicDir, 'profile.json'); +export const paykitReaderDir = join(localPath, 'paykit-reader'); +export const bitcoinBootstrapDir = join(localPath, 'bitcoin-bootstrap'); +export const paykitReaderPreparedPath = join(paykitReaderDir, 'prepared.v1.json'); +export const paykitReaderWorkerStatusPath = join(paykitReaderDir, 'worker.v1.json'); +export const paykitReaderOwnershipPath = join(paykitReaderDir, 'owner.lock'); +export const composeSecretsPath = join(localPath, 'compose-secrets.json'); +export const locksPostgresEnvPath = join(localPath, 'locks-postgres', 'locks-postgres.env'); +export const locksServerComposeEnvPath = join(localPath, 'locks-server', 'compose.env'); +export const paykitServerDir = join(localPath, 'paykit-server'); +export const paykitPostgresEnvPath = join(localPath, 'paykit-postgres', 'postgres.env'); +export const paykitServerEnvPath = join(paykitServerDir, 'paykit.env'); +export const paykitPublicConfigDir = join(localPath, 'paykit-config'); +export const paykitServerConfigPath = join(paykitPublicConfigDir, 'config.toml'); +export const bitcoinRpcEnvPath = join(localPath, 'bitcoin-rpc', 'bitcoin-rpc.env'); +export const pubkyHomeserverConfigPath = join(localPath, 'pubky-homeserver', 'config.toml'); export const validRoles = ['lock-server', 'content-creator', 'content-viewer']; @@ -75,13 +94,43 @@ export async function readJson(path) { } export async function writeJson(path, value, { mode = 0o644 } = {}) { - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode }); + await writeAtomicFile(path, `${JSON.stringify(value, null, 2)}\n`, mode); } export async function writeSecret(path, bytesOrText) { - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - await writeFile(path, bytesOrText, { mode: 0o600 }); + await writeAtomicFile(path, bytesOrText, 0o600); +} + +export async function writeAtomicFile(path, content, mode) { + const directory = dirname(path); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const directoryStat = await lstat(directory); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) { + throw new Error('generated state parent must be a directory'); + } + await chmod(directory, 0o700); + const temporary = join(directory, `.${basename(path)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`); + let handle; + try { + handle = await open(temporary, 'wx', mode); + await handle.writeFile(content); + await handle.chmod(mode); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporary, path); + } finally { + await handle?.close().catch(() => {}); + await rm(temporary, { force: true }).catch(() => {}); + } +} + +export async function readPrivateText(path) { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o077) !== 0) { + throw new Error('persisted secret must be a regular owner-only file'); + } + return readFile(path, 'utf8'); } export async function readMaybeText(path) { diff --git a/examples/js-sdk/scripts/lib/paykit-reader-helper.mjs b/examples/js-sdk/scripts/lib/paykit-reader-helper.mjs new file mode 100644 index 0000000..c4bf13b --- /dev/null +++ b/examples/js-sdk/scripts/lib/paykit-reader-helper.mjs @@ -0,0 +1,393 @@ +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 readReaderCreatorProfile({ + env = process.env, + readProfile = readJson, + loadProfile = loadRoleProfile, +} = {}) { + return env.PAYKIT_READER_CREATOR_PROFILE_PATH + ? readProfile(env.PAYKIT_READER_CREATOR_PROFILE_PATH) + : loadProfile('content-creator'); +} + +export async function resolveReaderEnvironment({ + env = process.env, + loadProfile = loadRoleProfile, +} = {}) { + const resolved = { ...env }; + if (!resolved.PAYKIT_READER_SERVER_PUBKY) { + const profile = await readReaderCreatorProfile({ env: resolved, loadProfile }).catch(() => undefined); + if (profile?.role !== 'content-creator' || !isCanonicalPubky(profile.pubky)) { + throw new Error('valid content-creator profile is required for Paykit reader setup'); + } + resolved.PAYKIT_READER_SERVER_PUBKY = profile.pubky; + } + requireReaderEnvironment(resolved); + return resolved; +} + +function readerHelperEnvironment(env) { + const restricted = Object.fromEntries(REQUIRED_ENV.map((name) => [name, env[name]])); + if (env.PATH) restricted.PATH = env.PATH; + return restricted; +} + +export async function signupReaderBestEffort({ + readConfig = readDemoConfig, + normalizeConfig = withInternalServiceUrls, + loadKeypair = loadRoleKeypair, + pubkyFactory = pubkyForConfig, + signup = signupBestEffort, + getHomeserverPublicKey = homeserverPublicKey, +} = {}) { + const config = normalizeConfig(await readConfig()); + const keypair = await loadKeypair('content-viewer'); + let signer; + let session; + try { + signer = pubkyFactory(config).signer(keypair); + session = await signup(signer, getHomeserverPublicKey(config)); + } finally { + session?.free(); + signer?.free(); + keypair.free(); + } +} + +export async function runReaderRegistration({ + signal, + timeoutMs = REGISTRATION_TIMEOUT_MS, + spawnProcess = spawn, + env = process.env, +} = {}) { + return runBoundedHelper({ + helperPath: process.execPath, + helperArgs: [REGISTRATION_SCRIPT], + input: { version: 1, operation: 'register' }, + timeoutMs, + signal, + spawnProcess, + spawnEnvironment: env.PATH ? { PATH: env.PATH } : {}, + classifyClose: ({ code, signal: closeSignal, stdout, stderr }) => { + if ( + code === 0 + && closeSignal === null + && stderr.length === 0 + && stdout.toString('utf8') === '{"version":1,"status":"registered"}\n' + ) { + return { status: 'success' }; + } + return { status: 'failed', error: 'protocol_failed' }; + }, + }); +} + +export function runRegistrationStep({ + ensureRegistered = runReaderRegistration, + signal, + timeoutMs = REGISTRATION_TIMEOUT_MS, +} = {}) { + if (!signal || signal.aborted) return Promise.resolve({ status: 'failed' }); + return (async () => { + const operationController = new AbortController(); + const operation = Promise.resolve() + .then(() => ensureRegistered({ signal: operationController.signal, timeoutMs })) + .then((result) => result ?? { status: 'success' }) + .catch(() => ({ status: 'failed', error: 'protocol_failed' })); + let timer; + let abortParent; + const interrupted = new Promise((resolveInterrupted) => { + abortParent = () => { + operationController.abort(); + resolveInterrupted({ status: 'failed' }); + }; + signal.addEventListener('abort', abortParent, { once: true }); + timer = setTimeout(() => { + operationController.abort(); + resolveInterrupted({ status: 'timeout' }); + }, timeoutMs); + }); + const outcome = await Promise.race([ + operation.then((result) => ({ kind: 'operation', result })), + interrupted.then((result) => ({ kind: 'interrupted', result })), + ]); + clearTimeout(timer); + signal.removeEventListener('abort', abortParent); + if (outcome.kind === 'operation') return outcome.result; + if (!operationController.signal.aborted) { + operationController.abort(); + } + await operation; + return outcome.result; + })(); +} + +export async function runReaderOperation({ + operation, + helperPath = process.env.PAYKIT_READER_DEMO_BIN || DEFAULT_HELPER_PATH, + env = process.env, + readerSecret, + ensureRegistered = runReaderRegistration, + spawnProcess = spawn, + signal = new AbortController().signal, + timeoutMs = operation === 'receive' ? RECEIVE_TIMEOUT_MS : PREPARE_TIMEOUT_MS, + registrationTimeoutMs = REGISTRATION_TIMEOUT_MS, +} = {}) { + if (!['prepare', 'receive'].includes(operation)) throw new Error('invalid reader helper operation'); + const resolvedEnvironment = await resolveReaderEnvironment({ env }); + if (operation === 'prepare') { + const registration = await runRegistrationStep({ + ensureRegistered, + signal, + timeoutMs: registrationTimeoutMs, + }); + if (registration.status !== 'success') return registration; + } + + let secret = readerSecret; + let input; + try { + secret ??= await loadRoleSecret('content-viewer'); + input = buildReaderHelperInput({ operation, readerSecret: secret }); + return await runBoundedHelper({ + helperPath, + input, + timeoutMs, + signal, + spawnProcess, + spawnEnvironment: readerHelperEnvironment(resolvedEnvironment), + classifyClose: ({ code, signal, stdout, stderr }) => { + if (code === 0 && signal === null && stderr.length === 0) { + return { + status: 'success', + value: parseReaderHelperSuccess({ operation, stdout: stdout.toString('utf8') }), + }; + } + if (code !== 0 && signal === null && stdout.length === 0) { + try { + return { status: 'failed', error: parseReaderFailure(stderr.toString('utf8')) }; + } catch {} + } + return { status: 'failed' }; + }, + }); + } finally { + secret?.fill(0); + if (input) input.reader_secret = ''; + } +} + +export function readerResultCategory(operation, result) { + if (result?.status === 'success') return { exitCode: 0, stream: 'stdout', value: result.value }; + if (result?.status === 'timeout') { + return { exitCode: 1, stream: 'stderr', message: `Paykit reader ${operation} timed out.` }; + } + const suffix = result?.error ? ` (${result.error})` : ''; + return { exitCode: 1, stream: 'stderr', message: `Paykit reader ${operation} failed${suffix}.` }; +} + +export function printReaderSuccess(operation, value, output = console.log) { + if (operation === 'prepare') { + output('Paykit reader prepared.'); + output(`Reader Pubky: ${value.reader_pubky}`); + output(`Receiver path: ${value.receiver_path}`); + return; + } + output('Paykit Payment Request received.'); + output(`Address: ${value.address}`); + output(`Amount: ${value.amount_sats} sats`); + output(`Payment request: ${value.payment_request_id}`); + output(`Pay: ${value.payment_command}`); + output(`Optional mining: ${value.optional_mining_command}`); +} diff --git a/examples/js-sdk/scripts/lib/paykit-reader-status.mjs b/examples/js-sdk/scripts/lib/paykit-reader-status.mjs new file mode 100644 index 0000000..33d99f0 --- /dev/null +++ b/examples/js-sdk/scripts/lib/paykit-reader-status.mjs @@ -0,0 +1,186 @@ +import { chmod, lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import { validateReaderOperatorResult } from './paykit-reader-helper.mjs'; +import { paykitReaderPreparedPath, paykitReaderWorkerStatusPath } from './paths.mjs'; + +const READER_PUBKY = /^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/; +const RECEIVER_PATH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/; +const RETRYABLE_ERRORS = new Set(['receive_timeout', 'protocol_failed']); +const FAILURE_ERRORS = new Set([ + 'invalid_input', + 'invalid_config', + 'invalid_state', + 'output_failed', + 'prepare_timeout', + 'worker_failed', +]); + +function exactKeys(value, expected) { + return value !== null + && typeof value === 'object' + && !Array.isArray(value) + && Object.keys(value).length === expected.length + && expected.every((key) => Object.hasOwn(value, key)); +} + +export function validatePreparedReaderStatus(value) { + if ( + !exactKeys(value, ['version', 'status', 'reader_pubky', 'receiver_path']) + || value.version !== 1 + || value.status !== 'prepared' + || typeof value.reader_pubky !== 'string' + || !READER_PUBKY.test(value.reader_pubky) + || typeof value.receiver_path !== 'string' + || !RECEIVER_PATH.test(value.receiver_path) + ) { + throw new Error('invalid prepared Paykit reader status'); + } + return Object.freeze({ ...value }); +} + +export async function writePreparedReaderStatus(value, path = paykitReaderPreparedPath) { + const status = validatePreparedReaderStatus(value); + await writeAtomicStatus(status, path); +} + +async function writeAtomicStatus(status, path) { + const directory = dirname(path); + const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`; + await mkdir(directory, { recursive: true, mode: 0o700 }); + let file; + try { + file = await open(temporary, 'wx', 0o600); + await file.writeFile(`${JSON.stringify(status)}\n`, 'utf8'); + await file.sync(); + await file.close(); + file = undefined; + await rename(temporary, path); + await chmod(path, 0o600); + } finally { + await file?.close().catch(() => {}); + await rm(temporary, { force: true }).catch(() => {}); + } +} + +export async function readPreparedReaderStatus(path = paykitReaderPreparedPath) { + try { + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) return null; + return validatePreparedReaderStatus(JSON.parse(await readFile(path, 'utf8'))); + } catch { + return null; + } +} + +export async function clearPreparedReaderStatus(path = paykitReaderPreparedPath) { + await rm(path, { force: true }); +} + +export function buildPreparedReaderBrowserStatus(prepared, profile) { + if ( + !prepared + || profile?.role !== 'content-viewer' + || profile.pubky !== prepared.reader_pubky + ) { + return { version: 1, prepared: false }; + } + return { version: 1, prepared: true, reader_pubky: prepared.reader_pubky }; +} + +export function validatePaykitReaderWorkerStatus(value) { + if (!value || value.version !== 1 || typeof value.state !== 'string') { + throw new Error('invalid Paykit reader worker status'); + } + if (value.state === 'starting' && exactKeys(value, ['version', 'state'])) { + return Object.freeze({ ...value }); + } + if (value.state === 'waiting_for_creator' && exactKeys(value, ['version', 'state'])) { + return Object.freeze({ ...value }); + } + if ( + value.state === 'waiting' + && exactKeys(value, ['version', 'state', 'reader_pubky']) + && READER_PUBKY.test(value.reader_pubky) + ) { + return Object.freeze({ ...value }); + } + if ( + value.state === 'retrying' + && exactKeys(value, ['version', 'state', 'reader_pubky', 'error']) + && READER_PUBKY.test(value.reader_pubky) + && RETRYABLE_ERRORS.has(value.error) + ) { + return Object.freeze({ ...value }); + } + if ( + value.state === 'failed' + && exactKeys(value, ['version', 'state', 'error']) + && FAILURE_ERRORS.has(value.error) + ) { + return Object.freeze({ ...value }); + } + if ( + value.state === 'request_received' + && exactKeys(value, [ + 'version', + 'state', + 'reader_pubky', + 'payment_request_id', + 'address', + 'asset', + 'amount_sats', + 'payment_command', + 'optional_mining_command', + ]) + && READER_PUBKY.test(value.reader_pubky) + ) { + validateReaderOperatorResult({ + version: value.version, + status: 'received', + payment_request_id: value.payment_request_id, + address: value.address, + asset: value.asset, + amount_sats: value.amount_sats, + payment_command: value.payment_command, + optional_mining_command: value.optional_mining_command, + }); + return Object.freeze({ ...value }); + } + throw new Error('invalid Paykit reader worker status'); +} + +export async function writePaykitReaderWorkerStatus( + value, + path = paykitReaderWorkerStatusPath, +) { + await writeAtomicStatus(validatePaykitReaderWorkerStatus(value), path); +} + +export async function readPaykitReaderWorkerStatus(path = paykitReaderWorkerStatusPath) { + try { + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) return null; + return validatePaykitReaderWorkerStatus(JSON.parse(await readFile(path, 'utf8'))); + } catch { + return null; + } +} + +export function buildPaykitReaderBrowserStatus( + worker, + profile, + { currentOwner = false, waitingForCreator = false } = {}, +) { + if (waitingForCreator) return { version: 1, state: 'waiting_for_creator' }; + if (!currentOwner) return { version: 1, state: 'starting' }; + if (!worker) return { version: 1, state: 'starting' }; + if ( + Object.hasOwn(worker, 'reader_pubky') + && (profile?.role !== 'content-viewer' || profile.pubky !== worker.reader_pubky) + ) { + return { version: 1, state: 'failed', error: 'identity_mismatch' }; + } + return { ...worker }; +} diff --git a/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs b/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs new file mode 100644 index 0000000..efa05e4 --- /dev/null +++ b/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs @@ -0,0 +1,366 @@ +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 waitForCreatorProfile({ + signal, + readProfile, + wait = waitForDelay, +} = {}) { + if (!signal || typeof readProfile !== 'function') { + throw new Error('Paykit reader creator wait dependencies are incomplete'); + } + while (!signal.aborted) { + let profile; + try { + profile = await readProfile(); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + profile = null; + } + if (profile === null) { + await wait(INITIAL_RETRY_MS, signal); + continue; + } + if ( + profile.role !== 'content-creator' + || !/^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(profile.pubky) + ) { + throw new Error('Paykit reader creator profile is invalid'); + } + return profile; + } + return null; +} + +export async function runPaykitReaderWorker({ + signal, + runOperation = runReaderOperation, + writePreparedStatus = writePreparedReaderStatus, + writeWorkerStatus, + wait = waitForDelay, + acquireOwnership = acquirePaykitReaderOwnership, + onOwnershipChange = () => {}, +} = {}) { + if (!signal || typeof writeWorkerStatus !== 'function') { + throw new Error('Paykit reader worker dependencies are incomplete'); + } + + if (signal.aborted) return { status: 'stopped' }; + const ownership = await acquireOwnership(); + if (signal.aborted) { + await ownership.release(); + return { status: 'stopped' }; + } + const lifecycleController = new AbortController(); + let ownershipLost = false; + let ownershipVisible = false; + const setOwnershipVisible = (visible) => { + if (ownershipVisible === visible) return; + ownershipVisible = visible; + onOwnershipChange(visible); + }; + const assertOwnershipHeld = () => { + if (ownershipLost) throw new Error('Paykit reader ownership was lost'); + }; + const abortLifecycle = () => lifecycleController.abort(); + signal.addEventListener('abort', abortLifecycle, { once: true }); + if (ownership.lost) { + void ownership.lost.then(() => { + ownershipLost = true; + lifecycleController.abort(); + setOwnershipVisible(false); + }); + } + const publishWorkerStatus = async (status) => { + assertOwnershipHeld(); + await writeWorkerStatus(status); + assertOwnershipHeld(); + }; + try { + await publishWorkerStatus({ version: 1, state: 'starting' }); + if (lifecycleController.signal.aborted) return { status: 'stopped' }; + setOwnershipVisible(true); + const result = await runOwnedWorker({ + signal: lifecycleController.signal, + runOperation, + writePreparedStatus, + writeWorkerStatus: publishWorkerStatus, + wait, + assertOwnershipHeld, + }); + assertOwnershipHeld(); + return result; + } finally { + signal.removeEventListener('abort', abortLifecycle); + setOwnershipVisible(false); + await ownership.release(); + } +} + +async function runOwnedWorker({ + signal, + runOperation, + writePreparedStatus, + writeWorkerStatus, + wait, + assertOwnershipHeld, +}) { + assertOwnershipHeld(); + if (signal.aborted) return { status: 'stopped' }; + let prepared; + try { + const result = await runOperation({ operation: 'prepare', signal }); + assertOwnershipHeld(); + if (signal.aborted) return { status: 'stopped' }; + if (result?.status !== 'success') { + const failed = failedStatus(result); + await writeWorkerStatus(failed); + return { status: 'failed', error: failed.error }; + } + prepared = result.value; + assertOwnershipHeld(); + await writePreparedStatus(prepared); + assertOwnershipHeld(); + if (signal.aborted) return { status: 'stopped' }; + await writeWorkerStatus({ + version: 1, + state: 'waiting', + reader_pubky: prepared.reader_pubky, + }); + } catch { + await writeWorkerStatus({ version: 1, state: 'failed', error: 'worker_failed' }); + return { status: 'failed', error: 'worker_failed' }; + } + + let retryMs = INITIAL_RETRY_MS; + let lastPaymentRequestId = null; + while (!signal.aborted) { + let result; + try { + result = await runOperation({ operation: 'receive', signal }); + } catch { + result = { status: 'failed', error: 'worker_failed' }; + } + assertOwnershipHeld(); + if (signal.aborted) return { status: 'stopped' }; + if (result?.status === 'success') { + if (result.value.payment_request_id === lastPaymentRequestId) { + await wait(retryMs, signal); + retryMs = Math.min(retryMs * 2, MAX_RETRY_MS); + continue; + } + await writeWorkerStatus(receivedStatus(prepared.reader_pubky, result.value)); + lastPaymentRequestId = result.value.payment_request_id; + retryMs = INITIAL_RETRY_MS; + continue; + } + + const error = result?.status === 'timeout' + ? 'receive_timeout' + : result?.error ?? 'worker_failed'; + if (!RETRYABLE_ERRORS.has(error)) { + const terminalError = TERMINAL_ERRORS.has(error) ? error : 'worker_failed'; + await writeWorkerStatus({ version: 1, state: 'failed', error: terminalError }); + return { status: 'failed', error: terminalError }; + } + if (lastPaymentRequestId === null) { + await writeWorkerStatus({ + version: 1, + state: 'retrying', + reader_pubky: prepared.reader_pubky, + error, + }); + } + await wait(retryMs, signal); + retryMs = Math.min(retryMs * 2, MAX_RETRY_MS); + } + return { status: 'stopped' }; +} + +function failedStatus(result) { + const candidate = result?.status === 'timeout' + ? 'prepare_timeout' + : result?.error ?? 'worker_failed'; + const error = TERMINAL_ERRORS.has(candidate) ? candidate : 'worker_failed'; + return { version: 1, state: 'failed', error }; +} + +function receivedStatus(readerPubky, value) { + return { + version: 1, + state: 'request_received', + reader_pubky: readerPubky, + payment_request_id: value.payment_request_id, + address: value.address, + asset: value.asset, + amount_sats: value.amount_sats, + payment_command: value.payment_command, + optional_mining_command: value.optional_mining_command, + }; +} + +async function waitForOwnershipReady(holder) { + let resolveClosed; + const closed = new Promise((resolve) => { resolveClosed = resolve; }); + holder.once('close', resolveClosed); + let stdout = ''; + let stderrBytes = 0; + let timeout; + const ready = new Promise((resolveReady, rejectReady) => { + const rejectSpawn = () => rejectReady(new Error('Paykit reader ownership holder failed')); + holder.once('error', rejectSpawn); + holder.stderr.on('data', (chunk) => { + stderrBytes += chunk.length; + if (stderrBytes > 1_024) rejectReady(new Error('Paykit reader ownership holder failed')); + }); + holder.stdout.on('data', (chunk) => { + stdout += chunk.toString('utf8'); + if (stdout === OWNERSHIP_READY) { + holder.removeListener('error', rejectSpawn); + resolveReady(); + } else if (stdout.length > OWNERSHIP_READY.length || !OWNERSHIP_READY.startsWith(stdout)) { + rejectReady(new Error('Paykit reader ownership holder failed')); + } + }); + timeout = setTimeout( + () => rejectReady(new Error('Paykit reader ownership acquisition timed out')), + OWNERSHIP_ACQUIRE_TIMEOUT_MS, + ); + }); + try { + await Promise.race([ + ready, + closed.then(() => { throw new Error('Paykit reader state is already owned by another process'); }), + ]); + clearTimeout(timeout); + return { closed }; + } catch (error) { + clearTimeout(timeout); + await stopOwnershipHolder(holder, closed); + throw error; + } +} + +async function stopOwnershipHolder(holder, closed) { + if (holder.exitCode !== null || holder.signalCode !== null) return; + holder.stdin.end(); + if (await closesWithin(closed, OWNERSHIP_RELEASE_TIMEOUT_MS)) return; + holder.kill('SIGTERM'); + if (await closesWithin(closed, OWNERSHIP_KILL_GRACE_MS)) return; + if (holder.exitCode !== null || holder.signalCode !== null) return; + holder.kill('SIGKILL'); + await closed; +} + +async function closesWithin(closed, milliseconds) { + let timer; + const result = await Promise.race([ + closed.then(() => true), + new Promise((resolve) => { timer = setTimeout(() => resolve(false), milliseconds); }), + ]); + clearTimeout(timer); + return result; +} + +function waitForDelay(milliseconds, signal) { + return new Promise((resolve) => { + if (signal.aborted) return resolve(); + const timer = setTimeout(resolve, milliseconds); + signal.addEventListener('abort', () => { + clearTimeout(timer); + resolve(); + }, { once: true }); + }); +} diff --git a/examples/js-sdk/scripts/lib/pubky.mjs b/examples/js-sdk/scripts/lib/pubky.mjs index 3ab6c57..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..d618161 --- /dev/null +++ b/examples/js-sdk/scripts/publish-creator-profile.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + creatorPublicProfilePath, + readJson, + roleProfilePath, + writeJson, +} from './lib/paths.mjs'; + +const CANONICAL_PUBKY = /^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/; + +export async function publishCreatorProfile({ + source = roleProfilePath('content-creator'), + destination = creatorPublicProfilePath, + profile, +} = {}) { + const creatorProfile = profile ?? await readJson(source); + if (creatorProfile?.role !== 'content-creator' || !CANONICAL_PUBKY.test(creatorProfile.pubky ?? '')) { + throw new Error('valid content-creator profile is required'); + } + const publicProfile = Object.freeze({ role: 'content-creator', pubky: creatorProfile.pubky }); + await writeJson(destination, publicProfile); + return publicProfile; +} + +async function main() { + try { + await publishCreatorProfile(); + process.stdout.write('Creator public profile published\n'); + } catch { + process.stderr.write('Creator public profile publication failed\n'); + process.exitCode = 1; + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/examples/js-sdk/scripts/receive-paykit-request.mjs b/examples/js-sdk/scripts/receive-paykit-request.mjs new file mode 100644 index 0000000..c9b7947 --- /dev/null +++ b/examples/js-sdk/scripts/receive-paykit-request.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import { + printReaderSuccess, + readerResultCategory, + runReaderOperation, +} from './lib/paykit-reader-helper.mjs'; +import { + acquirePaykitReaderOwnership, + assertStandaloneReaderOperationAllowed, +} from './lib/paykit-reader-worker.mjs'; + +const role = 'content-viewer'; + +export async function main({ + runOperation = runReaderOperation, + printSuccess = printReaderSuccess, + printError = console.error, + acquireOwnership = acquirePaykitReaderOwnership, +} = {}) { + assertStandaloneReaderOperationAllowed(); + const ownership = await acquireOwnership(); + try { + const result = await runOperation({ operation: 'receive' }); + const category = readerResultCategory('receive', result); + if (category.stream === 'stdout') printSuccess('receive', category.value); + else printError(category.message); + return category.exitCode; + } finally { + await ownership.release(); + } +} + +main() + .then((code) => { process.exitCode = code; }) + .catch(() => { + console.error(`Paykit reader receive could not start for ${role}.`); + process.exitCode = 2; + }); diff --git a/examples/js-sdk/scripts/register-paykit-reader.mjs b/examples/js-sdk/scripts/register-paykit-reader.mjs new file mode 100644 index 0000000..314d71c --- /dev/null +++ b/examples/js-sdk/scripts/register-paykit-reader.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { signupReaderBestEffort } from './lib/paykit-reader-helper.mjs'; + +const MAX_INPUT_BYTES = 1_024; + +export async function main({ input = process.stdin, signup = signupReaderBestEffort } = {}) { + const request = await readRequest(input); + if ( + request?.version !== 1 + || request.operation !== 'register' + || Object.keys(request).length !== 2 + ) { + throw new Error('invalid registration input'); + } + await signup(); + process.stdout.write('{"version":1,"status":"registered"}\n'); +} + +async function readRequest(input) { + const chunks = []; + let length = 0; + for await (const chunk of input) { + length += chunk.length; + if (length > MAX_INPUT_BYTES) throw new Error('registration input is too large'); + chunks.push(chunk); + } + const body = Buffer.concat(chunks).toString('utf8'); + if (!body.endsWith('\n') || body.slice(0, -1).includes('\n') || body.includes('\r')) { + throw new Error('invalid registration input'); + } + return JSON.parse(body.slice(0, -1)); +} + +const isMain = process.argv[1] + && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) { + main().catch(() => { + process.stderr.write('{"version":1,"error":"registration_failed"}\n'); + process.exitCode = 1; + }); +} diff --git a/examples/js-sdk/scripts/reset-paykit-demo.mjs b/examples/js-sdk/scripts/reset-paykit-demo.mjs new file mode 100644 index 0000000..90ed8c4 --- /dev/null +++ b/examples/js-sdk/scripts/reset-paykit-demo.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import { rm } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; + +import { bitcoinBootstrapDir, paykitReaderDir, repoRoot } from './lib/paths.mjs'; + +const COMPOSE_FILE = 'compose.paykit-local-demo.yaml'; +const dockerEnvironment = Object.fromEntries(Object.entries({ + HOME: process.env.HOME, + PATH: process.env.PATH, + DOCKER_HOST: process.env.DOCKER_HOST, + DOCKER_CONTEXT: process.env.DOCKER_CONTEXT, +}).filter(([, value]) => typeof value === 'string')); +const disposableVolumes = [ + 'pubky-locks-paykit-demo-locks-postgres', + 'pubky-locks-paykit-demo-paykit-postgres', + 'pubky-locks-paykit-demo-bitcoin', + 'pubky-locks-paykit-demo-fulcrum', +]; + +const result = spawnSync('docker', ['compose', '-f', COMPOSE_FILE, 'down', '--remove-orphans'], { + cwd: repoRoot, + shell: false, + stdio: 'inherit', + timeout: 120_000, + env: dockerEnvironment, +}); +if (result.error || result.status !== 0) { + console.error('reset-paykit-demo failed: docker compose down failed'); + process.exitCode = 1; +} else { + const removeVolumes = spawnSync('docker', ['volume', 'rm', '--force', ...disposableVolumes], { + cwd: repoRoot, + shell: false, + stdio: 'ignore', + timeout: 30_000, + env: dockerEnvironment, + }); + if (removeVolumes.error || removeVolumes.status !== 0) { + console.error('reset-paykit-demo failed: disposable volume removal failed'); + process.exitCode = 1; + } else { + await Promise.all([ + rm(bitcoinBootstrapDir, { recursive: true, force: true }), + rm(paykitReaderDir, { recursive: true, force: true }), + ]); + console.log('Paykit demo runtime reset; generated config and role identities preserved'); + } +} diff --git a/examples/js-sdk/scripts/smoke-paykit-compose.mjs b/examples/js-sdk/scripts/smoke-paykit-compose.mjs new file mode 100644 index 0000000..42ad8a0 --- /dev/null +++ b/examples/js-sdk/scripts/smoke-paykit-compose.mjs @@ -0,0 +1,407 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { chmod, lstat, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + buildPaykitServerConfig, + validatePaykitComposeEnvironment, +} from './lib/config.mjs'; +import { repoRoot, writeSecret } from './lib/paths.mjs'; +import { initializePaykitCompose } from './init-paykit-compose.mjs'; +import { readReaderCreatorProfile, resolveReaderEnvironment } from './lib/paykit-reader-helper.mjs'; +import { extractBip84AccountXpub } from './generate-paykit-account-tpub.mjs'; +import { resolveCreatorStaticPath } from './lib/creator-static-path.mjs'; +import { publishCreatorProfile } from './publish-creator-profile.mjs'; + +const lockServerPubky = 'pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo'; +const creatorPubky = 'pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy'; +const composeSource = await readFile(join(repoRoot, 'compose.paykit-local-demo.yaml'), 'utf8'); +const defaultComposeSource = await readFile(join(repoRoot, 'docker-compose.yml'), 'utf8'); +const creatorAppSource = await readFile(join(repoRoot, 'examples/js-sdk/app-iframe.js'), 'utf8'); +const creatorServerSource = await readFile(join(repoRoot, 'examples/js-sdk/scripts/start-demo-server.mjs'), 'utf8'); +const lockAuthoritySource = await readFile(join(repoRoot, 'locks-server/src/api/creator_authority.rs'), 'utf8'); +assert.match(composeSource, /^# Local development and demonstration only;/); +assert.match(composeSource, /^name: pubky-locks-paykit-demo$/m); +assert.match(creatorAppSource, /hasExactKeys\(event\.data, \['type', 'state', 'code'\]\)/); +assert.match(creatorAppSource, /body: JSON\.stringify\(\{ level \}\)/); +assert.match(creatorAppSource, /\[result\.authorizationUrl, result\.command\]\.filter\(Boolean\)/); +assert.doesNotMatch(creatorServerSource, /JSON\.stringify\(entry\)|url\.search/); +assert.match(creatorServerSource, /if \(!externalWallet\) \{\s+result\.command =/); +assert.doesNotMatch(lockAuthoritySource, /dev: legacy-connect authorization URL/); +assert.match(defaultComposeSource, /^services:/); +assert.doesNotMatch(defaultComposeSource, /^ paykit-server:/m); +assert.match(composeSource, /PAYKIT_READER_RECEIVER_PATH: bitkit\/wallet/); +assert.doesNotMatch(composeSource, /PAYKIT_READER_RECEIVER_PATH: paykit\/reader/); +assert.equal( + resolveCreatorStaticPath('/.local/compose-secrets.json', { repoRoot, examplesRoot: join(repoRoot, 'examples/js-sdk') }), + null, +); +assert.equal( + resolveCreatorStaticPath('/Cargo.toml', { repoRoot, examplesRoot: join(repoRoot, 'examples/js-sdk') }), + null, +); +assert.equal( + resolveCreatorStaticPath('/examples/js-sdk/index.html', { repoRoot, examplesRoot: join(repoRoot, 'examples/js-sdk') }), + join(repoRoot, 'examples/js-sdk/index.html'), +); +assert.equal( + resolveCreatorStaticPath('/examples/js-sdk/', { repoRoot, examplesRoot: join(repoRoot, 'examples/js-sdk') }), + join(repoRoot, 'examples/js-sdk/index.html'), +); +const readerBaseEnvironment = { + PAYKIT_READER_STATE_PATH: '/workspace/.local/paykit-reader/state.v1', + PAYKIT_READER_PUBKY_TESTNET_HOST: 'pubky-testnet', + PAYKIT_READER_RECEIVER_PATH: 'bitkit/wallet', + PAYKIT_READER_SERVER_PATH: 'bitkit/server', +}; +const testAccountXpub = `tpub${'A'.repeat(107)}`; +assert.deepEqual( + extractBip84AccountXpub({ + walletName: 'paykit-creator', + descriptors: [ + { active: true, internal: true, desc: `wpkh([01020304/84h/1h/0h]${testAccountXpub}/1/*)#internal` }, + { active: true, internal: false, desc: `wpkh([01020304/84h/1h/0h]${testAccountXpub}/0/*)#external` }, + ], + }), + { accountXpub: testAccountXpub, accountIndex: 0 }, +); +assert.throws( + () => extractBip84AccountXpub({ + walletName: 'paykit-creator', + descriptors: [ + { active: true, internal: false, desc: `wpkh([01020304/84h/0h/0h]xpub${'A'.repeat(107)}/0/*)#mainnet` }, + ], + }), + /external BIP84 testnet account descriptor/, +); +assert.equal( + (await resolveReaderEnvironment({ + env: readerBaseEnvironment, + loadProfile: async (role) => ({ role, pubky: creatorPubky }), + })).PAYKIT_READER_SERVER_PUBKY, + creatorPubky, +); +await assert.rejects( + resolveReaderEnvironment({ + env: readerBaseEnvironment, + loadProfile: async () => ({ role: 'content-viewer', pubky: creatorPubky }), + }), + /content-creator profile/, +); +const config = buildPaykitServerConfig({ lockServerPubky }); +assert.equal(config, `[http]\nlisten_addr = "0.0.0.0:3001"\n\n[locks]\ntrusted_public_key = "${lockServerPubky}"\n\n[setup]\nallowed_origins = ["http://127.0.0.1:8080", "http://localhost:8080"]\n\n[paykit]\nreceiver_path = "bitkit/server"\nreceiver_path_priority = ["bitkit"]\nnetwork = "testnet"\n\n[bitcoin]\nnetwork = "regtest"\n\n[electrum]\nendpoint = "tcp://fulcrum:50001"\npoll_interval = "1s"\nrequest_timeout = "10s"\nconnect_retries = 1\n\n[outbox]\npoll_interval = "500ms"\nbatch_size = 16\nlease_duration = "30s"\nretry_initial = "1s"\nretry_max = "5m"\n`); +assert.throws(() => buildPaykitServerConfig({ lockServerPubky: 'invalid' }), /Lock Server Pubky/); +assert.deepEqual(validatePaykitComposeEnvironment({ + PAYKIT_DATABASE_URL: 'postgres://paykit:secret@paykit-postgres:5432/paykit', + PAYKIT_MASTER_KEY: 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE', + BITCOIN_RPC_USER: 'bitcoin-user', + BITCOIN_RPC_PASSWORD: 'bitcoin-password', +}), { + databaseUrl: 'postgres://paykit:secret@paykit-postgres:5432/paykit', + masterKey: 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE', + bitcoinRpcUser: 'bitcoin-user', + bitcoinRpcPassword: 'bitcoin-password', +}); +for (const [name, value] of [ + ['PAYKIT_DATABASE_URL', ''], + ['PAYKIT_MASTER_KEY', 'short'], + ['BITCOIN_RPC_USER', 'bad user'], + ['BITCOIN_RPC_PASSWORD', ''], +]) { + const environment = { + PAYKIT_DATABASE_URL: 'postgres://paykit:secret@paykit-postgres:5432/paykit', + PAYKIT_MASTER_KEY: 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE', + BITCOIN_RPC_USER: 'bitcoin-user', + BITCOIN_RPC_PASSWORD: 'bitcoin-password', + [name]: value, + }; + assert.throws(() => validatePaykitComposeEnvironment(environment), new RegExp(name)); +} + +const generatedRoot = await mkdtemp(join(tmpdir(), 'locks-paykit-compose-')); +try { + const staticRoot = join(generatedRoot, 'static-root'); + const outsideStaticRoot = join(generatedRoot, 'outside-static-root'); + await mkdir(staticRoot); + await mkdir(outsideStaticRoot); + await writeFile(join(outsideStaticRoot, 'secret.txt'), 'not public'); + await symlink(join(outsideStaticRoot, 'secret.txt'), join(staticRoot, 'escape.txt')); + assert.equal( + resolveCreatorStaticPath('/examples/js-sdk/escape.txt', { repoRoot, examplesRoot: staticRoot }), + null, + 'creator static paths must not follow symlinks outside their allowlisted root', + ); + const outsideSecretPath = join(generatedRoot, 'outside-secret.txt'); + const secretLinkPath = join(generatedRoot, 'secret-link.txt'); + await writeFile(outsideSecretPath, 'outside remains unchanged', { mode: 0o600 }); + await symlink(outsideSecretPath, secretLinkPath); + await writeSecret(secretLinkPath, 'replacement'); + assert.equal(await readFile(outsideSecretPath, 'utf8'), 'outside remains unchanged'); + assert.equal((await lstat(secretLinkPath)).isSymbolicLink(), false, 'secret writes must replace symlinks'); + assert.equal((await stat(secretLinkPath)).mode & 0o777, 0o600); + const lockConfigPath = join(generatedRoot, 'lock-server.toml'); + await writeFile(lockConfigPath, `lock_server_public_key = "${lockServerPubky}"\n`, { mode: 0o600 }); + const first = await initializePaykitCompose({ root: generatedRoot }); + assert.equal(first.configGenerated, false); + const generatedFiles = [ + 'compose-secrets.json', + 'locks-postgres/locks-postgres.env', + 'locks-server/compose.env', + 'paykit-postgres/postgres.env', + 'paykit-server/paykit.env', + 'bitcoin-rpc/bitcoin-rpc.env', + 'pubky-homeserver/config.toml', + 'homegate-bridge/homegate.env', + ]; + for (const file of generatedFiles) { + assert.equal((await stat(join(generatedRoot, file))).mode & 0o777, 0o600, `${file} must be mode 0600`); + } + for (const directory of [ + 'js-sdk-demo', + 'demo-config', + 'creator-public', + 'bitcoin-bootstrap', + 'content-creator', + 'content-viewer', + 'paykit-reader', + 'locks-postgres', + 'paykit-postgres', + 'bitcoin-rpc', + 'pubky-homeserver', + 'paykit-server', + 'paykit-config', + 'homegate-bridge', + ]) { + assert.equal((await stat(join(generatedRoot, directory))).mode & 0o777, 0o700, `${directory} must be mode 0700`); + } + const paykitEnvironment = await readFile(join(generatedRoot, 'paykit-server/paykit.env'), 'utf8'); + assert.match(paykitEnvironment, /^PAYKIT_DATABASE_URL=postgres:\/\/paykit:[A-Za-z0-9_-]+@paykit-postgres:5432\/paykit$/m); + assert.match(paykitEnvironment, /^PAYKIT_MASTER_KEY=[A-Za-z0-9_-]{43}$/m); + const firstSecrets = await readFile(join(generatedRoot, 'compose-secrets.json'), 'utf8'); + await initializePaykitCompose({ root: generatedRoot }); + assert.equal(await readFile(join(generatedRoot, 'compose-secrets.json'), 'utf8'), firstSecrets); + await chmod(join(generatedRoot, 'compose-secrets.json'), 0o644); + await assert.rejects( + initializePaykitCompose({ root: generatedRoot }), + /persisted Compose secrets are invalid/, + ); + await chmod(join(generatedRoot, 'compose-secrets.json'), 0o600); + + const configured = await initializePaykitCompose({ + root: generatedRoot, + lockConfigPath, + configOnly: true, + }); + assert.equal(configured.configGenerated, true); + assert.equal( + await readFile(join(generatedRoot, 'paykit-config/config.toml'), 'utf8'), + buildPaykitServerConfig({ lockServerPubky }), + ); + assert.equal((await stat(join(generatedRoot, 'paykit-config/config.toml'))).mode & 0o777, 0o644); + + await writeFile( + join(generatedRoot, 'compose-secrets.json'), + `${JSON.stringify({ ...JSON.parse(firstSecrets), unexpected: true })}\n`, + ); + await assert.rejects( + initializePaykitCompose({ root: generatedRoot }), + /persisted Compose secrets are invalid/, + ); +} finally { + await rm(generatedRoot, { recursive: true, force: true }); +} + +const configOnlyRoot = await mkdtemp(join(tmpdir(), 'locks-paykit-public-config-')); +try { + const lockConfigPath = join(configOnlyRoot, 'lock-server.toml'); + await writeFile(lockConfigPath, `lock_server_public_key = "${lockServerPubky}"\n`, { mode: 0o644 }); + await initializePaykitCompose({ root: configOnlyRoot, lockConfigPath, configOnly: true }); + assert.equal( + await readFile(join(configOnlyRoot, 'paykit-config/config.toml'), 'utf8'), + buildPaykitServerConfig({ lockServerPubky }), + ); + await assert.rejects(readFile(join(configOnlyRoot, 'compose-secrets.json'), 'utf8'), { code: 'ENOENT' }); + + const privateProfilePath = join(configOnlyRoot, 'private-profile.json'); + const publicProfilePath = join(configOnlyRoot, 'creator-public/profile.json'); + await writeFile(privateProfilePath, `${JSON.stringify({ + role: 'content-creator', + pubky: creatorPubky, + homeserver: lockServerPubky, + created_at: '2026-01-01T00:00:00.000Z', + })}\n`, { mode: 0o600 }); + await publishCreatorProfile({ source: privateProfilePath, destination: publicProfilePath }); + assert.deepEqual(JSON.parse(await readFile(publicProfilePath, 'utf8')), { + role: 'content-creator', + pubky: creatorPubky, + }); + assert.deepEqual(await readReaderCreatorProfile({ + env: { PAYKIT_READER_CREATOR_PROFILE_PATH: publicProfilePath }, + loadProfile: async () => { throw new Error('private creator profile must not be loaded'); }, + }), { + role: 'content-creator', + pubky: creatorPubky, + }); + const readerFromPublicProfile = await resolveReaderEnvironment({ + env: { + ...readerBaseEnvironment, + PAYKIT_READER_CREATOR_PROFILE_PATH: publicProfilePath, + }, + loadProfile: async () => { throw new Error('private creator profile must not be loaded'); }, + }); + assert.equal(readerFromPublicProfile.PAYKIT_READER_SERVER_PUBKY, creatorPubky); + await publishCreatorProfile({ + profile: { role: 'content-creator', pubky: lockServerPubky }, + destination: publicProfilePath, + }); + assert.deepEqual(JSON.parse(await readFile(publicProfilePath, 'utf8')), { + role: 'content-creator', + pubky: lockServerPubky, + }); +} finally { + await rm(configOnlyRoot, { recursive: true, force: true }); +} + +const compose = await readFile(join(repoRoot, 'compose.paykit-local-demo.yaml'), 'utf8'); +const jsDemoDockerfile = await readFile(join(repoRoot, 'docker/js-demo.Dockerfile'), 'utf8'); +const locksEntrypoint = await readFile(join(repoRoot, 'docker/locks-server-compose-entrypoint.sh'), 'utf8'); +const resetScript = await readFile(join(repoRoot, 'examples/js-sdk/scripts/reset-paykit-demo.mjs'), 'utf8'); +const validateScript = await readFile(join(repoRoot, 'examples/js-sdk/scripts/validate-paykit-compose.mjs'), 'utf8'); +const accountScript = await readFile(join(repoRoot, 'examples/js-sdk/scripts/generate-paykit-account-tpub.mjs'), 'utf8'); +const packageJson = JSON.parse(await readFile(join(repoRoot, 'examples/js-sdk/package.json'), 'utf8')); +const bootstrapMode = (await stat(join(repoRoot, 'docker/bitcoin-bootstrap.sh'))).mode; +assert.notEqual(bootstrapMode & 0o111, 0, 'Bitcoin bootstrap script must be executable'); +assert.match(locksEntrypoint, /level = "info,pubky::actors::session=warn"/); +assert.match(compose, /RUST_LOG: \$\{LOCKS_RUST_LOG:-info,pubky::actors::session=warn\}/); +for (const required of ['--no-install-recommends ca-certificates util-linux', 'rm -rf /var/lib/apt/lists/*']) { + assert.ok(jsDemoDockerfile.includes(required), `JS demo image missing ${required}`); +} +for (const service of [ + 'compose-bootstrap:', + 'paykit-postgres:', + 'bitcoin:', + 'bitcoin-bootstrap:', + 'fulcrum:', + 'electrum-readiness:', + 'homegate-bridge:', + 'paykit-config:', + 'demo-config:', + 'paykit-server:', +]) { + assert.ok(compose.includes(` ${service}`), `missing Compose service ${service}`); +} +for (const required of [ + 'postgres:17-bookworm@sha256:4f736ae292687621d4dbe0d499ffd024a36bd2ee7d8ca6f2ccd4c800f047b394', + 'bitcoin/bitcoin:29.1@sha256:de62c536feb629bed65395f63afd02e3a7a777a3ec82fbed773d50336a739319', + 'cculianu/fulcrum:v1.11.1@sha256:70f06b93ab5863997992d4b4508312fe81ce576017e16ecc7e69c7d38165bdf2', + 'node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4', + 'additional_contexts:', + 'PUBKY_CORE_REV: 75eb1324f86e8caa16c41f18a2cd6b8e1909ee7b', + 'https://github.com/pubky/paykit-server.git#5ed3e8e849a16045c26c37a75068625dda333785', + 'https://github.com/pubky/paykit-rs.git#6b241878a9bba5cecea919c0298c3f90624be6ff:paykit-lib', + 'https://github.com/pubky/paykit-rs.git#6b241878a9bba5cecea919c0298c3f90624be6ff:paykit-sdk', + 'https://github.com/pubky/locks.git#df5ea1b6d8dcdec3a9b5a915c3f57bca69d75c8a', + '127.0.0.1:${LOCKS_PAYKIT_PORT:-3001}:3001', + '127.0.0.1:${LOCKS_READER_DEMO_PORT:-8088}:8081', + '127.0.0.1:${LOCKS_ELECTRUM_PORT:-60001}:50001', + '127.0.0.1:${LOCKS_HOMEGATE_PORT:-6288}:8082', + 'bitcoin-cli -conf=\\"$${BITCOIN_DATA}/bitcoin.conf\\" -regtest getblockchaininfo', + 'user: "1000:1000"', + '.local/bitcoin-bootstrap:/home/bitcoin/.bitcoin', + 'PAYKIT_CONFIG:', + '/run/compose-local/paykit-server/paykit.env', + '/run/compose-local/bitcoin-rpc.env', + './.local/locks-postgres:/run/compose-local:ro', + './.local/paykit-postgres:/run/compose-local/paykit-server:ro', + './.local/bitcoin-rpc:/run/compose-local:ro', + './.local/pubky-homeserver:/run/compose-local/pubky-homeserver:ro', + './.local/locks-server:/run/compose-local/locks-server:ro', + './.local/paykit-server:/run/compose-local/paykit-server:ro', + './.local/homegate-bridge:/run/compose-local/homegate-bridge:ro', + 'node examples/js-sdk/scripts/init-paykit-compose.mjs', + 'exec /entrypoint.sh Fulcrum', + 'PAYKIT_READER_DEMO_BIN:', + 'PAYKIT_COMPANION_AUTH_BIN:', + 'condition: service_healthy', + 'condition: service_completed_successfully', + 'locks-postgres-data:/var/lib/postgresql/data', + 'paykit-postgres-data:/var/lib/postgresql/data', + 'bitcoin-data:/home/bitcoin/.bitcoin', + 'fulcrum-data:/data', + '.local/paykit-config:/etc/paykit-server:ro', + 'check /health/live && check /health/ready', +]) { + assert.ok(compose.includes(required), `Compose missing ${required}`); +} +assert.ok(!compose.includes('env_file:'), 'Compose must bootstrap before loading generated environments'); +assert.ok(!compose.includes('chown -R 1000:1000 .local\n'), 'bootstrap must not rewrite ownership of the complete local state tree'); +assert.equal( + compose.split('./.local:/').length - 1, + 1, + 'only compose-bootstrap may mount the complete generated local state tree', +); +for (const siblingContext of ['../pubky-core', '../../Paykit/', '../paykit-rs', '../../Pubky/locks']) { + assert.ok(!compose.includes(siblingContext), `Compose must not require sibling context ${siblingContext}`); +} +for (const privateVolume of ['name: locks_lock-home', 'name: pubky-locks-demo-public']) { + assert.ok(!compose.includes(privateVolume), `Compose must not reuse private-era volume ${privateVolume}`); +} +assert.ok(!compose.includes('- ./:/workspace'), 'services must not mount the repository root'); +assert.ok(!compose.includes('- lock-home:/root'), 'demo services must not mount Lock Server identity state'); +const creatorService = compose.slice(compose.indexOf(' creator-demo:'), compose.indexOf('\n reader-demo:')); +assert.ok(creatorService.includes('--external-wallet'), 'creator must use the authenticated external wallet identity'); +assert.ok(creatorService.includes('rm -f /workspace/.local/creator-public/profile.json'), 'creator must clear stale public identity before external wallet auth'); +assert.ok(!creatorService.includes('create-user -- --role content-creator'), 'external wallet mode must not create a second creator identity'); +assert.ok(!creatorService.includes('.local/content-creator'), 'external wallet mode must not mount creator recovery state'); +assert.ok(!creatorService.includes('--allow-unhealthy'), 'creator preflight must fail closed'); +const readerService = compose.slice(compose.indexOf(' reader-demo:'), compose.indexOf('\nvolumes:')); +assert.ok(!readerService.includes('.local/js-sdk-demo'), 'reader must not mount creator session state'); +assert.ok(!readerService.includes('.local/content-creator'), 'reader must not mount creator recovery state'); +assert.ok(readerService.includes('.local/creator-public'), 'reader requires only the closed creator public profile'); +assert.ok(readerService.includes('PAYKIT_READER_WORKER_ENABLED: "1"'), 'reader must enable its embedded Paykit worker'); +assert.ok(readerService.includes('npm --prefix examples/js-sdk run create-user -- --role content-viewer'), 'reader must create or reuse its recovery identity'); +assert.ok(readerService.includes('exec node examples/js-sdk/scripts/start-reader-demo-server.mjs'), 'reader server must replace its bootstrap shell as PID 1'); +assert.ok(!readerService.includes('--allow-unhealthy'), 'reader preflight must fail closed'); +assert.ok(readerService.includes('healthcheck:'), 'reader must expose worker-aware Compose health'); +assert.ok(readerService.includes('http://127.0.0.1:8081/api/paykit-reader/status'), 'reader health must use the closed worker status endpoint'); +assert.ok(readerService.includes('restart: unless-stopped'), 'reader worker must have an explicit restart policy'); +assert.ok(!compose.includes('POSTGRES_PASSWORD: locks'), 'database credentials must not be committed inline'); +assert.ok(!compose.includes('./locks-sdk/bindings/js/pkg:/workspace/locks-sdk/bindings/js/pkg'), 'demo images must provide their own WASM package'); +for (const required of ['FROM rust:1.91.1-slim-bookworm@sha256:8514999d4786ef12efe89239e86b3d0a021b94b9d35108c8efe6c79ca7dc1a65 AS locks-sdk-wasm', 'cargo install wasm-pack --version 0.13.1 --locked', 'wasm-pack build --target web --out-dir pkg', 'COPY --from=locks-sdk-wasm']) { + assert.ok(jsDemoDockerfile.includes(required), `JS demo image missing ${required}`); +} +assert.ok(locksEntrypoint.includes('LOCKS_PUBLIC_CONFIG'), 'Lock Server must publish an explicit public artifact'); +for (const required of ['[paykit]', 'server_url = "http://127.0.0.1:3001"', 'minimum_confirmations = 0']) { + assert.ok(locksEntrypoint.includes(required), `Locks generated config missing ${required}`); +} +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..0ac43ea 100644 --- a/examples/js-sdk/scripts/start-demo-server.mjs +++ b/examples/js-sdk/scripts/start-demo-server.mjs @@ -1,15 +1,21 @@ #!/usr/bin/env node import { createReadStream, existsSync, statSync } from 'node:fs'; -import { readFile, writeFile } from 'node:fs/promises'; import { createServer } from 'node:http'; -import { extname, join, normalize, resolve, sep } from 'node:path'; +import { extname, join } from 'node:path'; import { AuthFlowKind, pubkyForConfig } from './lib/pubky.mjs'; -import { contentCreatorSessionPath, examplesRoot, parseArgs, repoRoot, writeJson } from './lib/paths.mjs'; +import { contentCreatorSessionPath, examplesRoot, parseArgs, repoRoot } from './lib/paths.mjs'; import { readDemoConfig, validateDemoConfig, pubkyAuthRelayInboxUrl, withInternalServiceUrls } from './lib/config.mjs'; +import { + readCreatorDemoSessionForCurrentRole, + writeCreatorDemoSessionForCurrentRole, +} from './lib/creator-session-state.mjs'; +import { resolveCreatorStaticPath } from './lib/creator-static-path.mjs'; +import { publishCreatorProfile } from './publish-creator-profile.mjs'; const args = parseArgs(); const allowUnhealthy = Boolean(args['allow-unhealthy']); +const externalWallet = Boolean(args['external-wallet']); const config = await readDemoConfig(); const serviceConfig = withInternalServiceUrls(config); const port = Number(new URL(config.demoServer.url).port || 8080); @@ -35,6 +41,8 @@ if (allowUnhealthy && preflightStatus.checks.some((check) => !check.ok)) { console.warn('Starting despite unhealthy preflight because --allow-unhealthy was provided.'); } +if (externalWallet) await readCurrentCreatorSession(); + const server = createServer(async (request, response) => { try { const url = new URL(request.url, config.demoServer.url); @@ -49,8 +57,13 @@ const server = createServer(async (request, response) => { return sendJson(response, debugSnapshot(config, preflightStatus)); } if (request.method === 'POST' && url.pathname === '/api/client-log') { - const entry = await readJsonBody(request); - console.log(`[client:${entry.level ?? 'info'}] ${entry.event ?? 'event'} ${JSON.stringify(entry)}`); + let level; + try { + level = await readClientLogLevel(request); + } catch { + return sendJson(response, { error: 'invalid client log' }, 400); + } + console.log(`[creator-client:${level}] event`); return sendJson(response, { ok: true }); } if (request.method === 'POST' && url.pathname === '/api/demo-auth/start') { @@ -61,8 +74,8 @@ const server = createServer(async (request, response) => { return sendJson(response, await demoAuthStatus()); } if (request.method === 'GET' && url.pathname === '/auth/lock-server/callback') { - // Only the redirect flow (app.js) navigates here; it gets index.html. The iframe flow uses - // direct postMessage delivery and never hits this callback route. + // Legacy callback URLs still resolve to the creator page. Both current creator pages use + // direct postMessage delivery and never navigate to this route. return serveStatic(response, join(examplesRoot, 'index.html')); } if (request.method !== 'GET') { @@ -71,8 +84,8 @@ const server = createServer(async (request, response) => { } return servePath(url.pathname, response); } catch (error) { - console.error(error); - sendJson(response, { error: error.message }, 500); + console.error('demo request failed'); + sendJson(response, { error: 'request failed' }, 500); } }); @@ -96,33 +109,42 @@ async function startDemoAuth() { demoAuthPromise = activeDemoAuthFlow .awaitApproval() .then(async (session) => { - await writeJson(contentCreatorSessionPath, { + const creatorSession = { role: 'content-creator', pubky: session.info.publicKey.toString(), capabilities: session.info.capabilities, exported_session: session.export(), authenticated_at: new Date().toISOString(), - }); - activeDemoAuthFlow = null; + }; + await writeCreatorDemoSessionForCurrentRole(creatorSession, sessionStateOptions()); + if (externalWallet) await publishCreatorProfile({ profile: creatorSession }); return session; }) .catch((error) => { + console.error(`demo auth failed: ${error instanceof Error ? error.message : String(error)}`); + }) + .finally(() => { activeDemoAuthFlow = null; - console.error(`demo auth failed: ${error.message}`); + activeDemoAuthUrl = null; + activeDemoAuthStartedAt = null; + demoAuthPromise = null; }); } - return { + const result = { authenticated: false, role: 'content-creator', authorizationUrl: activeDemoAuthUrl, startedAt: activeDemoAuthStartedAt, - command: `npm --prefix examples/js-sdk run authenticate -- --role content-creator --auth "${activeDemoAuthUrl}"`, }; + if (!externalWallet) { + result.command = `npm --prefix examples/js-sdk run authenticate -- --role content-creator --auth "${activeDemoAuthUrl}"`; + } + return result; } async function demoAuthStatus() { - if (existsSync(contentCreatorSessionPath)) { - const session = JSON.parse(await readFile(contentCreatorSessionPath, 'utf8')); + const session = await readCurrentCreatorSession(); + if (session) { if (debugEnabled) { console.log(`[demo] demo-auth persisted session pubky=${session.pubky} path=./.local/js-sdk-demo/content-creator-session.json`); } @@ -144,7 +166,17 @@ async function demoAuthStatus() { } async function hasPersistedDemoSession() { - return existsSync(contentCreatorSessionPath); + return Boolean(await readCurrentCreatorSession()); +} + +function sessionStateOptions() { + return externalWallet ? { profilePath: null } : {}; +} + +async function readCurrentCreatorSession() { + const session = await readCreatorDemoSessionForCurrentRole(sessionStateOptions()); + if (session && externalWallet) await publishCreatorProfile({ profile: session }); + return session; } function publicBrowserConfig(source) { @@ -152,6 +184,7 @@ function publicBrowserConfig(source) { return { demoServer: source.demoServer, lockServer: source.lockServer, + paykit: source.paykit, testnet: source.testnet, paths: { lockServerCallback: `${source.demoServer.url}/auth/lock-server/callback`, @@ -163,7 +196,7 @@ function logStartupDiagnostics(source, preflight) { if (!debugEnabled) return; const authRelay = pubkyAuthRelayInboxUrl(source.testnet.httpRelay); console.log('[demo] startup diagnostics'); - console.log('[demo] config path: ./.local/js-sdk-demo/config.json'); + console.log('[demo] config path: ./.local/demo-config/config.json'); console.log(`[demo] demoServer.url=${source.demoServer.url}`); console.log(`[demo] lockServer.url=${source.lockServer.url}`); console.log(`[demo] lockServer.pubky=${source.lockServer.pubky}`); @@ -200,7 +233,7 @@ function logRequest(request, url) { if (!debugEnabled) return; const interesting = url.pathname.startsWith('/api/') || url.pathname.startsWith('/auth/') || url.pathname === '/config.json'; if (!interesting) return; - console.log(`[demo] ${request.method} ${url.pathname}${url.search}`); + console.log(`[demo] ${request.method} ${url.pathname}`); } async function runPreflight(source) { @@ -214,6 +247,9 @@ async function runPreflight(source) { push('config', false, error.message); } + const wasmPackage = join(repoRoot, 'locks-sdk/bindings/js/pkg/locks_sdk_wasm_bg.wasm'); + push('WASM package', existsSync(wasmPackage), existsSync(wasmPackage) ? 'present' : 'missing'); + await checkHttp(`${source.lockServer.url}/healthz`, 'lock-server /healthz', checks, (status) => status >= 200 && status < 300); await checkHttp(`${source.lockServer.url}/readyz`, 'lock-server /readyz', checks, (status) => status >= 200 && status < 300); await checkHttp(source.testnet.pkarrRelay, 'pkarr relay', checks, (status) => status < 500); // status < 500 @@ -243,27 +279,31 @@ async function checkHttp(url, name, checks, acceptsStatus) { } } -async function readJsonBody(request) { +async function readClientLogLevel(request) { const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const text = Buffer.concat(chunks).toString('utf8'); - if (!text) return {}; - try { - return JSON.parse(text); - } catch (error) { - return { level: 'warn', event: 'invalid-client-log-json', raw: text, parseError: error.message }; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 128) throw new Error('client log too large'); + chunks.push(chunk); } + const text = Buffer.concat(chunks).toString('utf8'); + const value = JSON.parse(text); + if ( + value === null + || typeof value !== 'object' + || Array.isArray(value) + || Object.keys(value).length !== 1 + || !Object.hasOwn(value, 'level') + || !['info', 'warn', 'error'].includes(value.level) + ) throw new Error('invalid client log'); + return value.level; } function servePath(pathname, response) { - let relative = pathname === '/' ? '/examples/js-sdk/' : pathname; - if (relative === '/examples/js-sdk/') relative = '/examples/js-sdk/index.html'; - if (relative.startsWith('/pkg/')) relative = `/locks-sdk/bindings/js${relative}`; - - const normalized = normalize(relative).replace(/^\/+/, ''); - const filePath = resolve(repoRoot, normalized); - if (!filePath.startsWith(repoRoot + sep)) { - response.writeHead(403).end('forbidden'); + const filePath = resolveCreatorStaticPath(pathname, { repoRoot, examplesRoot }); + if (!filePath) { + response.writeHead(404).end('not found'); return; } if (!existsSync(filePath) || statSync(filePath).isDirectory()) { diff --git a/examples/js-sdk/scripts/start-reader-demo-server.mjs b/examples/js-sdk/scripts/start-reader-demo-server.mjs index 99e8c4b..12ad8ef 100644 --- a/examples/js-sdk/scripts/start-reader-demo-server.mjs +++ b/examples/js-sdk/scripts/start-reader-demo-server.mjs @@ -1,10 +1,22 @@ #!/usr/bin/env node import { createReadStream, existsSync, statSync } from 'node:fs'; import { createServer } from 'node:http'; -import { extname, join, normalize, resolve, sep } from 'node:path'; +import { extname, join, normalize } from 'node:path'; import { readDemoConfig, validateDemoConfig, pubkyAuthRelayInboxUrl, withInternalServiceUrls } from './lib/config.mjs'; -import { examplesRoot, parseArgs, repoRoot } from './lib/paths.mjs'; +import { examplesRoot, parseArgs, readJson, repoRoot, roleProfilePath } from './lib/paths.mjs'; +import { resolveExistingPathWithin } from './lib/creator-static-path.mjs'; +import { readReaderCreatorProfile } from './lib/paykit-reader-helper.mjs'; +import { + runPaykitReaderWorker, + supervisePaykitReaderWorker, + waitForCreatorProfile, +} from './lib/paykit-reader-worker.mjs'; +import { + buildPaykitReaderBrowserStatus, + readPaykitReaderWorkerStatus, + writePaykitReaderWorkerStatus, +} from './lib/paykit-reader-status.mjs'; const args = parseArgs(process.argv.slice(2)); const allowUnhealthy = args['allow-unhealthy'] === true; @@ -14,6 +26,14 @@ const readerUrl = new URL(config.demoServer.url); readerUrl.port = String(args.port ?? 8081); const readerServerUrl = readerUrl.toString().replace(/\/$/, ''); const preflightStatus = await runPreflight(serviceConfig); +const externalReaderPubky = process.env.PAYKIT_EXTERNAL_READER_PUBKY?.trim() ?? ''; +if (externalReaderPubky && !/^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(externalReaderPubky)) { + throw new Error('PAYKIT_EXTERNAL_READER_PUBKY must be a canonical Pubky'); +} +const workerEnabled = !externalReaderPubky && process.env.PAYKIT_READER_WORKER_ENABLED === '1'; +const workerController = new AbortController(); +let workerOwnsState = false; +let workerWaitingForCreator = workerEnabled; logStartupDiagnostics(config, preflightStatus); @@ -34,6 +54,9 @@ const server = createServer(async (request, response) => { try { const url = new URL(request.url, readerServerUrl); logRequest(request, url); + if (request.method === 'GET' && url.pathname === '/api/health') { + return sendJson(response, { status: 'ok' }); + } if (request.method === 'GET' && url.pathname === '/config.json') { return sendJson(response, publicBrowserConfig(config)); } @@ -43,9 +66,21 @@ const server = createServer(async (request, response) => { if (request.method === 'GET' && url.pathname === '/api/debug/config') { return sendJson(response, debugSnapshot(config, preflightStatus)); } + if (request.method === 'GET' && url.pathname === '/api/paykit-reader/status') { + const status = await publicPaykitReaderStatus({ + currentOwner: workerEnabled && workerOwnsState, + waitingForCreator: workerWaitingForCreator, + }); + return sendJson(response, status, ['starting', 'failed'].includes(status.state) ? 503 : 200); + } if (request.method === 'POST' && url.pathname === '/api/client-log') { - const entry = await readJsonBody(request); - console.log(`[reader-client:${entry.level ?? 'info'}] ${entry.event ?? 'event'} ${JSON.stringify(entry)}`); + let level; + try { + level = await readClientLogLevel(request); + } catch { + return sendJson(response, { error: 'invalid client log' }, 400); + } + console.log(`[reader-client:${level}] event`); return sendJson(response, { ok: true }); } if (request.method !== 'GET') { @@ -56,9 +91,9 @@ const server = createServer(async (request, response) => { return serveStatic(response, join(examplesRoot, 'reader.html')); } return servePath(url.pathname, response); - } catch (error) { - console.error(error); - sendJson(response, { error: error.message }, 500); + } catch { + console.error('reader demo request failed'); + sendJson(response, { error: 'request failed' }, 500); } }); @@ -67,6 +102,16 @@ server.listen(Number(readerUrl.port), () => { console.log(`Open ${readerServerUrl}/reader/`); }); +const workerTask = workerEnabled + ? supervisePaykitReaderWorker( + runWorkerAfterCreatorProfile(), + { onTerminalFailure: handleTerminalWorkerFailure }, + ) + : Promise.resolve({ status: 'stopped' }); +for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, () => { void shutdown(signal); }); +} + function publicBrowserConfig(source) { validateDemoConfig(source); return { @@ -77,6 +122,62 @@ function publicBrowserConfig(source) { }; } +async function runWorkerAfterCreatorProfile() { + await waitForCreatorProfile({ + signal: workerController.signal, + readProfile: readReaderCreatorProfile, + }); + if (workerController.signal.aborted) return { status: 'stopped' }; + workerWaitingForCreator = false; + return runPaykitReaderWorker({ + signal: workerController.signal, + writeWorkerStatus: writePaykitReaderWorkerStatus, + onOwnershipChange: (owned) => { workerOwnsState = owned; }, + }); +} + +export async function publicPaykitReaderStatus({ + readWorker = readPaykitReaderWorkerStatus, + readProfile = () => readJson(roleProfilePath('content-viewer')), + currentOwner = false, + waitingForCreator = false, +} = {}) { + if (externalReaderPubky) { + return { version: 1, state: 'waiting', reader_pubky: externalReaderPubky }; + } + const [worker, profile] = await Promise.all([ + Promise.resolve().then(readWorker).catch(() => null), + Promise.resolve().then(readProfile).catch(() => null), + ]); + return buildPaykitReaderBrowserStatus(worker, profile, { currentOwner, waitingForCreator }); +} + +async function handleTerminalWorkerFailure(error) { + const terminalError = [ + 'invalid_input', + 'invalid_config', + 'invalid_state', + 'output_failed', + 'prepare_timeout', + ].includes(error) ? error : 'worker_failed'; + console.error(`[reader-demo] Paykit reader worker stopped (${terminalError})`); + workerController.abort(); + process.exitCode = 1; + server.close(); +} + +let shutdownStarted = false; +async function shutdown(signal) { + if (shutdownStarted) return; + shutdownStarted = true; + console.log(`[reader-demo] shutting down after ${signal}`); + workerController.abort(); + await Promise.allSettled([ + workerTask, + new Promise((resolveClose) => server.close(resolveClose)), + ]); +} + function debugSnapshot(source, preflight) { return { checkedAt: new Date().toISOString(), @@ -97,6 +198,8 @@ async function runPreflight(source) { } catch (error) { checks.push({ name: 'config', ok: false, message: error.message }); } + const wasmPackage = join(repoRoot, 'locks-sdk/bindings/js/pkg/locks_sdk_wasm_bg.wasm'); + checks.push({ name: 'WASM package', ok: existsSync(wasmPackage), message: existsSync(wasmPackage) ? 'present' : 'missing' }); await checkHttp(`${source.lockServer.url}/healthz`, 'lock-server /healthz', checks, (status) => status === 200); await checkHttp(`${source.lockServer.url}/readyz`, 'lock-server /readyz', checks, (status) => status === 200); await checkHttp(source.testnet.pkarrRelay, 'pkarr relay', checks, (status) => status === 200 || status === 404); @@ -115,11 +218,13 @@ async function checkHttp(url, name, checks, acceptsStatus) { function servePath(pathname, response) { const normalizedPath = normalize(pathname).replace(/^[/\\]+/, ''); - const resolved = normalizedPath.startsWith('locks-sdk/') - ? resolve(repoRoot, normalizedPath) - : resolve(examplesRoot, normalizedPath); - const allowedRoots = [examplesRoot, resolve(repoRoot, 'locks-sdk/bindings/js/pkg')]; - if (!allowedRoots.some((root) => resolved === root || resolved.startsWith(`${root}${sep}`))) { + const packagePrefix = 'locks-sdk/bindings/js/pkg/'; + const resolved = normalizedPath.startsWith(packagePrefix) + ? resolveExistingPathWithin(join(repoRoot, 'locks-sdk/bindings/js/pkg'), normalizedPath.slice(packagePrefix.length)) + : normalizedPath.startsWith('locks-sdk/') + ? null + : resolveExistingPathWithin(examplesRoot, normalizedPath); + if (!resolved) { response.writeHead(404).end('not found'); return; } @@ -152,15 +257,31 @@ function contentType(filePath) { } function sendJson(response, body, status = 200) { - response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); + response.writeHead(status, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + }); response.end(JSON.stringify(body)); } -async function readJsonBody(request) { +async function readClientLogLevel(request) { const chunks = []; - for await (const chunk of request) chunks.push(chunk); - if (chunks.length === 0) return {}; - return JSON.parse(Buffer.concat(chunks).toString('utf8')); + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 128) throw new Error('client log too large'); + chunks.push(chunk); + } + const value = JSON.parse(Buffer.concat(chunks).toString('utf8')); + if ( + value === null + || typeof value !== 'object' + || Array.isArray(value) + || Object.keys(value).length !== 1 + || !Object.hasOwn(value, 'level') + || !['info', 'warn', 'error'].includes(value.level) + ) throw new Error('invalid client log'); + return value.level; } function logStartupDiagnostics(source, preflight) { diff --git a/examples/js-sdk/scripts/test-paykit-reader-worker.mjs b/examples/js-sdk/scripts/test-paykit-reader-worker.mjs new file mode 100644 index 0000000..bf2be2d --- /dev/null +++ b/examples/js-sdk/scripts/test-paykit-reader-worker.mjs @@ -0,0 +1,447 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; + +import { runBoundedHelper } from './authenticate-paykit.mjs'; +import { parsePaykitReaderBrowserStatus } from '../reader-flow.js'; +import { runRegistrationStep } from './lib/paykit-reader-helper.mjs'; +import { + acquirePaykitReaderOwnership, + runPaykitReaderWorker, + supervisePaykitReaderWorker, + waitForCreatorProfile, +} from './lib/paykit-reader-worker.mjs'; +import { + buildPaykitReaderBrowserStatus, + readPaykitReaderWorkerStatus, + validatePaykitReaderWorkerStatus, + writePaykitReaderWorkerStatus, +} from './lib/paykit-reader-status.mjs'; + +const readerPubky = `pubky${'y'.repeat(52)}`; +const received = { + version: 1, + status: 'received', + payment_request_id: '12345678-1234-4123-8123-123456789abc', + address: 'bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqdku202', + asset: 'BTC', + amount_sats: '50000', + payment_command: "docker compose --file ./compose.paykit-local-demo.yaml exec -T bitcoin sh -ec 'bitcoin-cli -conf=\"$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', +}); +assert.deepEqual( + parsePaykitReaderBrowserStatus({ version: 1, state: 'waiting_for_creator' }), + { version: 1, state: 'waiting_for_creator' }, +); + +let creatorProfileReads = 0; +const creatorProfileWaits = []; +assert.deepEqual(await waitForCreatorProfile({ + signal: new AbortController().signal, + readProfile: async () => { + creatorProfileReads += 1; + if (creatorProfileReads < 3) { + const error = new Error('profile not published yet'); + error.code = 'ENOENT'; + throw error; + } + return { role: 'content-creator', pubky: readerPubky }; + }, + wait: async (milliseconds) => { creatorProfileWaits.push(milliseconds); }, +}), { role: 'content-creator', pubky: readerPubky }); +assert.equal(creatorProfileReads, 3); +assert.deepEqual(creatorProfileWaits, [1_000, 1_000]); +await assert.rejects( + waitForCreatorProfile({ + signal: new AbortController().signal, + readProfile: async () => { throw new SyntaxError('invalid profile JSON'); }, + }), + /invalid profile JSON/, +); + +const newerReceived = { + ...received, + payment_request_id: 'abcdef12-3456-4789-8123-123456789abc', +}; +const continuousOperations = []; +const continuousStatuses = []; +let continuousWaits = 0; +const continuousController = new AbortController(); +const continuousTimeout = setTimeout(() => continuousController.abort(), 100); +await runPaykitReaderWorker({ + signal: continuousController.signal, + runOperation: async ({ operation }) => { + continuousOperations.push(operation); + if (operation === 'prepare') { + return { status: 'success', value: { version: 1, status: 'prepared', reader_pubky: readerPubky, receiver_path: 'bitkit/wallet' } }; + } + const receiveCount = continuousOperations.filter((value) => value === 'receive').length; + if (receiveCount === 1) return { status: 'success', value: received }; + if (receiveCount === 2) return { status: 'success', value: received }; + return { status: 'success', value: newerReceived }; + }, + writePreparedStatus: async () => {}, + writeWorkerStatus: async (value) => { + continuousStatuses.push(value); + if (value.payment_request_id === newerReceived.payment_request_id) { + continuousController.abort(); + } + }, + wait: async () => { continuousWaits += 1; }, + acquireOwnership: async () => ({ release: async () => {} }), + onOwnershipChange: () => {}, +}); +clearTimeout(continuousTimeout); +assert.deepEqual(continuousOperations, ['prepare', 'receive', 'receive', 'receive']); +assert.deepEqual(continuousStatuses.map(({ state }) => state), [ + 'starting', + 'waiting', + 'request_received', + 'request_received', +]); +assert.equal(continuousWaits, 1); +assert.equal(continuousStatuses.at(-1).payment_request_id, newerReceived.payment_request_id); +assert.throws( + () => parsePaykitReaderBrowserStatus({ ...statuses.at(-1), raw_request: 'private' }), + /invalid Paykit reader status/, +); + +const helperSignals = []; +class AbortableHelper extends EventEmitter { + constructor() { + super(); + this.stdin = new PassThrough(); + this.stdout = new PassThrough(); + this.stderr = new PassThrough(); + queueMicrotask(() => this.emit('spawn')); + } + + kill(signal) { + helperSignals.push(signal); + queueMicrotask(() => this.emit('close', null, signal)); + return true; + } +} +const helperController = new AbortController(); +const helperResult = runBoundedHelper({ + helperPath: '/test/helper', + input: { version: 1 }, + classifyClose: () => ({ status: 'success' }), + timeoutMs: 1_000, + signal: helperController.signal, + spawnProcess: () => new AbortableHelper(), +}); +helperController.abort(); +assert.deepEqual(await helperResult, { status: 'failed' }); +assert.deepEqual(helperSignals, ['SIGTERM']); + +const preAborted = new AbortController(); +preAborted.abort(); +let preAbortedSpawned = false; +assert.deepEqual(await runBoundedHelper({ + helperPath: '/test/helper', + input: { version: 1 }, + classifyClose: () => ({ status: 'success' }), + timeoutMs: 1_000, + signal: preAborted.signal, + spawnProcess: () => { + preAbortedSpawned = true; + return new AbortableHelper(); + }, +}), { status: 'failed' }); +assert.equal(preAbortedSpawned, false); + +const statusRoot = await mkdtemp(join(tmpdir(), 'paykit-reader-worker-')); +try { + const ownershipPath = join(statusRoot, 'owner.lock'); + const firstOwner = await acquirePaykitReaderOwnership(ownershipPath); + assert.equal((await stat(ownershipPath)).mode & 0o777, 0o600); + await assert.rejects( + acquirePaykitReaderOwnership(ownershipPath), + /already owned/, + ); + await firstOwner.release(); + const replacementOwner = await acquirePaykitReaderOwnership(ownershipPath); + await replacementOwner.release(); + await writeFile(ownershipPath, 'stale', { mode: 0o600 }); + const recoveredOwner = await acquirePaykitReaderOwnership(ownershipPath); + await recoveredOwner.release(); + + const statusPath = join(statusRoot, 'worker.v1.json'); + await writePaykitReaderWorkerStatus(statuses.at(-1), statusPath); + assert.equal((await stat(statusPath)).mode & 0o777, 0o600); + assert.deepEqual(await readPaykitReaderWorkerStatus(statusPath), statuses.at(-1)); + assert.throws( + () => validatePaykitReaderWorkerStatus({ ...statuses.at(-1), raw_request: 'private' }), + /invalid Paykit reader worker status/, + ); + assert.deepEqual( + buildPaykitReaderBrowserStatus( + statuses.at(-1), + { role: 'content-viewer', pubky: readerPubky }, + { currentOwner: true }, + ), + statuses.at(-1), + ); + assert.deepEqual( + buildPaykitReaderBrowserStatus( + statuses.at(-1), + { role: 'content-viewer', pubky: `pubky${'b'.repeat(52)}` }, + { currentOwner: true }, + ), + { version: 1, state: 'failed', error: 'identity_mismatch' }, + ); + assert.deepEqual( + buildPaykitReaderBrowserStatus( + statuses.at(-1), + { role: 'content-viewer', pubky: readerPubky }, + { currentOwner: false }, + ), + { version: 1, state: 'starting' }, + ); + assert.deepEqual( + buildPaykitReaderBrowserStatus(null, null, { + currentOwner: false, + waitingForCreator: true, + }), + { version: 1, state: 'waiting_for_creator' }, + ); +} finally { + await rm(statusRoot, { recursive: true, force: true }); +} + +const acquisitionAbortController = new AbortController(); +let finishAcquisition; +let acquisitionAbortReleased = 0; +let operationStartedAfterAcquisitionAbort = false; +const acquisitionAbortWorker = runPaykitReaderWorker({ + signal: acquisitionAbortController.signal, + acquireOwnership: () => new Promise((resolveOwnership) => { + finishAcquisition = () => resolveOwnership({ + release: async () => { acquisitionAbortReleased += 1; }, + }); + }), + runOperation: async () => { + operationStartedAfterAcquisitionAbort = true; + return { status: 'failed', error: 'worker_failed' }; + }, + writeWorkerStatus: async () => {}, +}); +acquisitionAbortController.abort(); +finishAcquisition(); +assert.deepEqual(await acquisitionAbortWorker, { status: 'stopped' }); +assert.equal(operationStartedAfterAcquisitionAbort, false); +assert.equal(acquisitionAbortReleased, 1); + +const startingAbortController = new AbortController(); +let operationStartedAfterStartingAbort = false; +assert.deepEqual(await runPaykitReaderWorker({ + signal: startingAbortController.signal, + acquireOwnership: async () => ({ release: async () => {} }), + runOperation: async () => { + operationStartedAfterStartingAbort = true; + return { status: 'failed', error: 'worker_failed' }; + }, + writeWorkerStatus: async ({ state }) => { + if (state === 'starting') startingAbortController.abort(); + }, +}), { status: 'stopped' }); +assert.equal(operationStartedAfterStartingAbort, false); + +const prepareAbortController = new AbortController(); +const prepareAbortWrites = []; +let preparedCheckpointAfterAbort = false; +assert.deepEqual(await runPaykitReaderWorker({ + signal: prepareAbortController.signal, + acquireOwnership: async () => ({ release: async () => {} }), + runOperation: async () => { + prepareAbortController.abort(); + return { + status: 'success', + value: { + version: 1, + status: 'prepared', + reader_pubky: readerPubky, + receiver_path: 'bitkit/wallet', + }, + }; + }, + writePreparedStatus: async () => { preparedCheckpointAfterAbort = true; }, + writeWorkerStatus: async ({ state }) => { prepareAbortWrites.push(state); }, +}), { status: 'stopped' }); +assert.equal(preparedCheckpointAfterAbort, false); +assert.deepEqual(prepareAbortWrites, ['starting']); + +let resolveOwnershipLoss; +let ownershipLossReleased = 0; +const ownershipLossWrites = []; +const ownershipLossStates = []; +await assert.rejects(runPaykitReaderWorker({ + signal: new AbortController().signal, + acquireOwnership: async () => ({ + lost: new Promise((resolveLoss) => { resolveOwnershipLoss = resolveLoss; }), + release: async () => { ownershipLossReleased += 1; }, + }), + runOperation: async () => ({ + status: 'success', + value: { + version: 1, + status: 'prepared', + reader_pubky: readerPubky, + receiver_path: 'bitkit/wallet', + }, + }), + writePreparedStatus: async () => { + resolveOwnershipLoss(); + await new Promise((resolve) => setImmediate(resolve)); + }, + writeWorkerStatus: async ({ state }) => { ownershipLossWrites.push(state); }, + onOwnershipChange: (owned) => ownershipLossStates.push(owned), +}), /ownership was lost/); +assert.deepEqual(ownershipLossWrites, ['starting']); +assert.deepEqual(ownershipLossStates, [true, false]); +assert.equal(ownershipLossReleased, 1); + +let rejectedWorkerReleased = 0; +const rejectedWorker = runPaykitReaderWorker({ + signal: new AbortController().signal, + writeWorkerStatus: async () => { throw new Error('status write failed'); }, + acquireOwnership: async () => ({ release: async () => { rejectedWorkerReleased += 1; } }), +}); +let supervisedError; +assert.deepEqual(await supervisePaykitReaderWorker(rejectedWorker, { + onTerminalFailure: async (error) => { supervisedError = error; }, +}), { status: 'failed', error: 'worker_failed' }); +assert.equal(supervisedError, 'worker_failed'); +assert.equal(rejectedWorkerReleased, 1); + +const registrationController = new AbortController(); +let registrationCancelled = false; +let registrationCleanupComplete = false; +const registration = runRegistrationStep({ + signal: registrationController.signal, + timeoutMs: 60_000, + ensureRegistered: ({ signal }) => new Promise((resolveRegistration) => { + const finishCleanup = () => { + registrationCancelled = true; + setTimeout(() => { + registrationCleanupComplete = true; + resolveRegistration({ status: 'failed' }); + }, 5); + }; + if (signal.aborted) finishCleanup(); + else signal.addEventListener('abort', finishCleanup, { once: true }); + }), +}); +registrationController.abort(); +assert.deepEqual(await registration, { status: 'failed' }); +assert.equal(registrationCancelled, true); +assert.equal(registrationCleanupComplete, true); + +let registrationTimedOutSignal = false; +let timedRegistrationCleanupComplete = false; +const timedRegistration = runRegistrationStep({ + signal: new AbortController().signal, + timeoutMs: 5, + ensureRegistered: ({ signal }) => new Promise((resolveRegistration) => { + signal.addEventListener('abort', () => { + registrationTimedOutSignal = true; + setTimeout(() => { + timedRegistrationCleanupComplete = true; + resolveRegistration({ status: 'failed' }); + }, 5); + }, { once: true }); + }), +}); +assert.deepEqual(await timedRegistration, { status: 'timeout' }); +assert.equal(registrationTimedOutSignal, true); +assert.equal(timedRegistrationCleanupComplete, true); + +console.log('Paykit reader worker check passed'); diff --git a/examples/js-sdk/scripts/validate-paykit-compose.mjs b/examples/js-sdk/scripts/validate-paykit-compose.mjs new file mode 100644 index 0000000..bd4218b --- /dev/null +++ b/examples/js-sdk/scripts/validate-paykit-compose.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { repoRoot } from './lib/paths.mjs'; + +const MAX_MODEL_BYTES = 2 * 1024 * 1024; +const COMPOSE_FILE = 'compose.paykit-local-demo.yaml'; +const REQUIRED_SERVICES = [ + 'postgres', + 'paykit-postgres', + 'bitcoin', + 'bitcoin-bootstrap', + 'fulcrum', + 'electrum-readiness', + 'pubky-testnet', + 'homegate-bridge', + 'locks-server', + 'paykit-config', + 'demo-config', + 'paykit-server', + 'creator-demo', + 'reader-demo', +]; + +export function validateSafeComposeModel(model) { + if (!model || typeof model !== 'object' || Array.isArray(model) || !model.services) { + throw new Error('Compose returned an invalid model'); + } + for (const service of REQUIRED_SERVICES) { + if (!model.services[service]) throw new Error(`Compose model is missing service ${service}`); + } + for (const [name, service] of Object.entries(model.services)) { + if (typeof service.image === 'string' && !service.build && !service.image.includes('@sha256:')) { + throw new Error(`external image for ${name} is not digest-pinned`); + } + } + for (const name of [ + 'bitcoin-bootstrap', + 'electrum-readiness', + 'paykit-config', + 'demo-config', + 'homegate-bridge', + 'creator-demo', + 'reader-demo', + ]) { + const service = model.services[name]; + if (String(service.user ?? '') !== '1000:1000') { + throw new Error(`${name} must run as an unprivileged user`); + } + } + for (const name of ['creator-demo', 'reader-demo']) { + const service = model.services[name]; + for (const mount of service.volumes ?? []) { + if ( + mount.source === '.' + || mount.source === 'lock-home' + || mount.source === 'pubky-locks-demo-identity' + || mount.source === 'locks_lock-home' + || mount.target === '/workspace' + || mount.target === '/workspace/.local' + || mount.target === '/root' + || mount.target === '/var/lib/pubky-lock' + ) { + throw new Error(`${name} crosses a private source or identity boundary`); + } + } + } + const readerTargets = new Set( + (model.services['reader-demo'].volumes ?? []).map((mount) => mount.target), + ); + for (const privateTarget of [ + '/workspace/.local/js-sdk-demo', + '/workspace/.local/content-creator', + ]) { + if (readerTargets.has(privateTarget)) { + throw new Error('reader-demo crosses a creator-private state boundary'); + } + } + const bootstrapHome = (model.services['bitcoin-bootstrap'].volumes ?? []).find( + (mount) => mount.target === '/home/bitcoin/.bitcoin', + ); + if ( + bootstrapHome?.type !== 'bind' + || !bootstrapHome.source.endsWith('/.local/bitcoin-bootstrap') + ) { + throw new Error('bitcoin-bootstrap must use reset-managed scratch state'); + } + for (const service of Object.values(model.services)) { + for (const port of service.ports ?? []) { + if (port.host_ip !== '127.0.0.1') throw new Error('published demo ports must bind to loopback'); + } + } + return model; +} + +export function validatePaykitCompose({ run = runCompose } = {}) { + const quiet = run(['compose', '-f', COMPOSE_FILE, 'config', '--quiet'], { capture: false }); + if (quiet.error || quiet.status !== 0 || quiet.signal) { + throw new Error('Compose quiet validation failed'); + } + const rendered = run( + ['compose', '-f', COMPOSE_FILE, 'config', '--no-env-resolution', '--format', 'json'], + { capture: true }, + ); + if (rendered.error || rendered.status !== 0 || rendered.signal) { + throw new Error('Compose safe model rendering failed'); + } + if (Buffer.byteLength(rendered.stdout ?? '', 'utf8') > MAX_MODEL_BYTES) { + throw new Error('Compose model exceeded the output limit'); + } + let model; + try { + model = JSON.parse(rendered.stdout); + } catch { + throw new Error('Compose returned invalid JSON'); + } + validateSafeComposeModel(model); +} + +function runCompose(args, { capture }) { + const environment = Object.fromEntries(Object.entries({ + HOME: process.env.HOME, + PATH: process.env.PATH, + DOCKER_HOST: process.env.DOCKER_HOST, + DOCKER_CONTEXT: process.env.DOCKER_CONTEXT, + DOCKER_CONFIG: process.env.DOCKER_CONFIG, + }).filter(([, value]) => typeof value === 'string')); + return spawnSync('docker', args, { + cwd: repoRoot, + shell: false, + encoding: capture ? 'utf8' : undefined, + stdio: capture ? ['ignore', 'pipe', 'ignore'] : 'ignore', + timeout: 30_000, + maxBuffer: MAX_MODEL_BYTES, + env: environment, + }); +} + +function main() { + try { + validatePaykitCompose(); + process.stdout.write('Paykit Compose validation passed\n'); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : 'Compose validation failed'}\n`); + process.exitCode = 1; + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main(); diff --git a/locks-core/src/lock_policy.rs b/locks-core/src/lock_policy.rs index 2c6d57c..6fb2e13 100644 --- a/locks-core/src/lock_policy.rs +++ b/locks-core/src/lock_policy.rs @@ -336,6 +336,35 @@ pub enum PaykitPaymentParamsValidationError { InvalidAmount, #[error("paykit-payment asset must be a non-empty string")] InvalidAsset, + #[error("paykit-payment payment_in must be a positive whole-hour JSON u64")] + InvalidPaymentIn, +} + +/// Validated public parameters for a `paykit-payment` criterion. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaykitPaymentParams { + recipient_pubky: CreatorPubky, + amount: String, + asset: String, + payment_in: u64, +} + +impl PaykitPaymentParams { + pub fn recipient_pubky(&self) -> &CreatorPubky { + &self.recipient_pubky + } + + pub fn amount(&self) -> &str { + &self.amount + } + + pub fn asset(&self) -> &str { + &self.asset + } + + pub fn payment_in(&self) -> u64 { + self.payment_in + } } /// Invalid v1 content-lock policy containing a `paykit-payment` criterion. @@ -370,22 +399,32 @@ pub struct Criterion { impl Criterion { /// Validates verifier-specific public criterion params. pub fn validate_params(&self) -> Result<(), PaykitPaymentParamsValidationError> { + self.paykit_payment_params().map(|_| ()) + } + + /// Returns typed parameters when this is a `paykit-payment` criterion. + pub fn paykit_payment_params( + &self, + ) -> Result, PaykitPaymentParamsValidationError> { match self.verifier_type { - VerifierType::DevStatic => Ok(()), - VerifierType::PaykitPayment => validate_paykit_payment_params(&self.params), + VerifierType::DevStatic => Ok(None), + VerifierType::PaykitPayment => validate_paykit_payment_params(&self.params).map(Some), } } } fn validate_paykit_payment_params( params: &Value, -) -> Result<(), PaykitPaymentParamsValidationError> { +) -> Result { let object = params .as_object() .ok_or(PaykitPaymentParamsValidationError::NotObject)?; for key in object.keys() { - if !matches!(key.as_str(), "recipient_pubky" | "amount" | "asset") { + if !matches!( + key.as_str(), + "recipient_pubky" | "amount" | "asset" | "payment_in" + ) { return Err(PaykitPaymentParamsValidationError::UnknownField( key.clone(), )); @@ -398,7 +437,7 @@ fn validate_paykit_payment_params( .ok_or(PaykitPaymentParamsValidationError::MissingField( "recipient_pubky", ))?; - CreatorPubky::from_str(recipient_pubky) + let recipient_pubky = CreatorPubky::from_str(recipient_pubky) .map_err(|_| PaykitPaymentParamsValidationError::InvalidRecipientPubky)?; let amount = object @@ -422,7 +461,21 @@ fn validate_paykit_payment_params( return Err(PaykitPaymentParamsValidationError::InvalidAsset); } - Ok(()) + let payment_in = object + .get("payment_in") + .ok_or(PaykitPaymentParamsValidationError::MissingField( + "payment_in", + ))? + .as_u64() + .filter(|payment_in| *payment_in > 0) + .ok_or(PaykitPaymentParamsValidationError::InvalidPaymentIn)?; + + Ok(PaykitPaymentParams { + recipient_pubky, + amount: amount.to_owned(), + asset: asset.to_owned(), + payment_in, + }) } /// Logic expression over criterion identifiers. @@ -473,7 +526,7 @@ mod tests { AccessPolicy, CONTENT_LOCK_VERSION, ContentLock, ContentLockValidationError, Criterion, GuardedResource, GuardedResourceValidationError, LockLogic, LockServerConfig, PRIVATE_PROOF_BUNDLE_PATH_PREFIX, PRIVATE_RESOURCE_CONTENT_PATH_PREFIX, - PUBLIC_LOCKS_APP_PATH_PREFIX, PaykitPaymentParamsValidationError, + PUBLIC_LOCKS_APP_PATH_PREFIX, PaykitPaymentParams, PaykitPaymentParamsValidationError, PaykitPaymentPolicyValidationError, SecondaryGuardedResource, VerifierType, verified_proof_bundle_path, }; @@ -550,7 +603,8 @@ mod tests { params: json!({ "recipient_pubky": recipient_pubky.to_string(), "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 }), } } @@ -868,77 +922,91 @@ mod tests { "recipient_pubky": test_pubky_identity(), "amount": "50000", "asset": "BTC", + "payment_in": 24, }), }; assert_eq!(criterion.validate_params(), Ok(())); + let params = criterion.paykit_payment_params().unwrap().unwrap(); + assert_eq!(params.amount(), "50000"); + assert_eq!(params.asset(), "BTC"); + assert_eq!(params.payment_in(), 24); + assert_eq!( + params.recipient_pubky().to_string(), + criterion.params["recipient_pubky"] + ); + let _: PaykitPaymentParams = params; } #[test] fn paykit_payment_params_reject_invalid_shapes() { + let recipient = test_pubky_identity(); + let overflow = serde_json::from_str(&format!( + r#"{{"recipient_pubky":"{recipient}","amount":"50000","asset":"BTC","payment_in":18446744073709551616}}"# + )) + .unwrap(); for (params, expected) in [ (json!(null), PaykitPaymentParamsValidationError::NotObject), ( - json!({ - "recipient_pubky": test_pubky_identity(), - "amount": "50000", - "asset": "BTC", - "memo": "extra", - }), + json!({ "recipient_pubky": recipient, "amount": "50000", "asset": "BTC", "payment_in": 24, "memo": "extra" }), PaykitPaymentParamsValidationError::UnknownField("memo".to_owned()), ), ( - json!({ "amount": "50000", "asset": "BTC" }), + json!({ "amount": "50000", "asset": "BTC", "payment_in": 24 }), PaykitPaymentParamsValidationError::MissingField("recipient_pubky"), ), ( - json!({ "recipient_pubky": test_pubky_identity(), "asset": "BTC" }), + json!({ "recipient_pubky": recipient, "asset": "BTC", "payment_in": 24 }), PaykitPaymentParamsValidationError::MissingField("amount"), ), ( - json!({ "recipient_pubky": test_pubky_identity(), "amount": "50000" }), + json!({ "recipient_pubky": recipient, "amount": "50000", "payment_in": 24 }), PaykitPaymentParamsValidationError::MissingField("asset"), ), ( - json!({ - "recipient_pubky": "not-a-pubky", - "amount": "50000", - "asset": "BTC", - }), + json!({ "recipient_pubky": recipient, "amount": "50000", "asset": "BTC" }), + PaykitPaymentParamsValidationError::MissingField("payment_in"), + ), + ( + json!({ "recipient_pubky": "not-a-pubky", "amount": "50000", "asset": "BTC", "payment_in": 24 }), PaykitPaymentParamsValidationError::InvalidRecipientPubky, ), ( - json!({ - "recipient_pubky": test_pubky_identity(), - "amount": "0", - "asset": "BTC", - }), + json!({ "recipient_pubky": recipient, "amount": "0", "asset": "BTC", "payment_in": 24 }), PaykitPaymentParamsValidationError::InvalidAmount, ), ( - json!({ - "recipient_pubky": test_pubky_identity(), - "amount": "0.5", - "asset": "BTC", - }), + json!({ "recipient_pubky": recipient, "amount": "0.5", "asset": "BTC", "payment_in": 24 }), PaykitPaymentParamsValidationError::InvalidAmount, ), ( - json!({ - "recipient_pubky": test_pubky_identity(), - "amount": 50000, - "asset": "BTC", - }), + json!({ "recipient_pubky": recipient, "amount": 50000, "asset": "BTC", "payment_in": 24 }), PaykitPaymentParamsValidationError::InvalidAmount, ), ( - json!({ - "recipient_pubky": test_pubky_identity(), - "amount": "50000", - "asset": "", - }), + json!({ "recipient_pubky": recipient, "amount": "50000", "asset": "", "payment_in": 24 }), PaykitPaymentParamsValidationError::InvalidAsset, ), + ( + json!({ "recipient_pubky": recipient, "amount": "50000", "asset": "BTC", "payment_in": 0 }), + PaykitPaymentParamsValidationError::InvalidPaymentIn, + ), + ( + json!({ "recipient_pubky": recipient, "amount": "50000", "asset": "BTC", "payment_in": -1 }), + PaykitPaymentParamsValidationError::InvalidPaymentIn, + ), + ( + json!({ "recipient_pubky": recipient, "amount": "50000", "asset": "BTC", "payment_in": 1.5 }), + PaykitPaymentParamsValidationError::InvalidPaymentIn, + ), + ( + json!({ "recipient_pubky": recipient, "amount": "50000", "asset": "BTC", "payment_in": "24" }), + PaykitPaymentParamsValidationError::InvalidPaymentIn, + ), + ( + overflow, + PaykitPaymentParamsValidationError::InvalidPaymentIn, + ), ] { let criterion = Criterion { criterion_id: "criterion-1".to_owned(), @@ -1214,4 +1282,17 @@ mod tests { without_override.lock_id().unwrap() ); } + + #[test] + fn changing_paykit_payment_in_changes_lock_id() { + let mut shorter = content_lock_fixture(); + shorter.criteria = vec![paykit_criterion("payment", &shorter.creator)]; + shorter.lock_logic = LockLogic::All { + criteria: vec!["payment".to_owned()], + }; + let mut longer = shorter.clone(); + longer.criteria[0].params["payment_in"] = json!(25); + + assert_ne!(shorter.lock_id().unwrap(), longer.lock_id().unwrap()); + } } diff --git a/locks-e2e/tests/creator_publishing_http.rs b/locks-e2e/tests/creator_publishing_http.rs index 89456c1..7a30a35 100644 --- a/locks-e2e/tests/creator_publishing_http.rs +++ b/locks-e2e/tests/creator_publishing_http.rs @@ -377,7 +377,8 @@ async fn creator_publishing_http_rejects_invalid_paykit_payment_params() { "params": { "recipient_pubky": creator().to_string(), "amount": "0", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 } }]), standard_lock_logic(), @@ -399,7 +400,8 @@ async fn creator_publishing_http_rejects_invalid_paykit_payment_params() { "params": { "recipient_pubky": "pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo", "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 } }]), standard_lock_logic(), @@ -515,7 +517,8 @@ async fn creator_publishing_http_paykit_payment_flow_creates_invoice_verifies_an "params": { "recipient_pubky": creator().to_string(), "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 } }]), standard_lock_logic(), @@ -757,7 +760,8 @@ fn paykit_criterion_json(criterion_id: &str) -> serde_json::Value { "params": { "recipient_pubky": creator().to_string(), "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 } }) } 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 identity is prepared automatically', '/reader-app.js'], + initConfig: ['~/.pubky-lock/config.toml', './.local/demo-config/config.json', 'lock_server_public_key', 'http://127.0.0.1:15411', 'http://127.0.0.1:15412', '127.0.0.1:6881'], + createUser: ['requiredRole', 'Keypair.random()', 'createRecoveryFile', 'profile.json', '--force', 'content-creator', 'content-viewer', 'lock-server', 'clearPreparedReaderStatus', 'clearCreatorDemoSession'], authenticate: ['requiredRole', 'readAuthFromPrompt', 'signer.signup', 'approveAuthRequest', '--auth', 'already'], - startServer: ['createServer', '--allow-unhealthy', 'pubkyAuthRelayInboxUrl', '/api/demo-auth/start', '/api/demo-auth/status', '/config.json', 'awaitApproval', 'content-creator-session.json', '/healthz', '/readyz'], - startReaderServer: ['createServer', '--allow-unhealthy', '/reader/', '/config.json', '/api/preflight', '/api/debug/config', '/api/client-log', '8081', 'never proxy'], - pathsLib: ['localPath', 'roleDir', 'demoConfigPath', 'contentCreatorSessionPath'], - configLib: ['readDemoConfig', 'writeDemoConfig', 'parseLockServerTomlPublicKey', 'pubkyAuthRelayInboxUrl', 'validateDemoConfig'], - pubkyLib: ["from '@synonymdev/pubky'", 'Pubky.testnet', 'Keypair.fromRecoveryFile', 'AuthFlowKind', 'PublicKey.from'], + authenticatePaykit: [ + 'PAYKIT_COMPANION_AUTH_BIN', + '/usr/local/bin/paykit-companion-auth', + '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://127.0.0.1:3001'", "['paykit', 'url']"], + pubkyLib: ["from '@synonymdev/pubky'", 'Pubky.testnet', 'Keypair.fromRecoveryFile', 'keypair.secret()', 'loadRoleSecret', 'AuthFlowKind', 'PublicKey.from'], creator: [ "from '../../locks-sdk/bindings/js/pkg/locks_sdk_wasm.js'", 'startCreatorConnect', @@ -107,11 +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,1071 @@ 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'); +const firstCreatorPubky = 'pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy'; +const secondCreatorPubky = 'pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo'; +try { + writeFileSync(creatorSessionTestPath, '{"exported_session":"sensitive"}'); + await clearCreatorDemoSession(creatorSessionTestPath); + assert.equal(existsSync(creatorSessionTestPath), false); + await clearCreatorDemoSession(creatorSessionTestPath); + + writeFileSync(creatorProfileTestPath, JSON.stringify({ role: 'content-creator', pubky: secondCreatorPubky })); + writeFileSync(creatorSessionTestPath, JSON.stringify({ role: 'content-creator', pubky: firstCreatorPubky, exported_session: 'old-secret' })); + assert.equal(await readCreatorDemoSessionForCurrentRole({ + sessionPath: creatorSessionTestPath, + profilePath: creatorProfileTestPath, + }), null); + assert.equal(existsSync(creatorSessionTestPath), false); + + await assert.rejects( + writeCreatorDemoSessionForCurrentRole( + { role: 'content-creator', pubky: firstCreatorPubky, exported_session: 'late-old-secret' }, + { sessionPath: creatorSessionTestPath, profilePath: creatorProfileTestPath }, + ), + /creator identity changed during demo authentication/, + ); + assert.equal(existsSync(creatorSessionTestPath), false); + + const currentSession = { role: 'content-creator', pubky: secondCreatorPubky, exported_session: 'current-secret' }; + await writeCreatorDemoSessionForCurrentRole(currentSession, { + sessionPath: creatorSessionTestPath, + profilePath: creatorProfileTestPath, + }); + assert.deepEqual( + await readCreatorDemoSessionForCurrentRole({ + sessionPath: creatorSessionTestPath, + profilePath: creatorProfileTestPath, + }), + currentSession, + ); + assert.equal(statSync(creatorSessionTestPath).mode & 0o777, 0o600); + const externalSession = { role: 'content-creator', pubky: firstCreatorPubky, exported_session: 'external-secret' }; + await writeCreatorDemoSessionForCurrentRole(externalSession, { + sessionPath: creatorSessionTestPath, + profilePath: null, + }); + assert.deepEqual( + await readCreatorDemoSessionForCurrentRole({ + sessionPath: creatorSessionTestPath, + profilePath: null, + }), + externalSession, + ); +} finally { + rmSync(creatorSessionTestDir, { recursive: true, force: true }); +} + +const { buildCreatorLockPolicy } = await import(pathToFileURL(files.creatorPolicy).href); +assert.deepEqual( + buildCreatorLockPolicy({ criterionId: 'criterion-1', devStaticSatisfied: true }), + { + criteria: [{ + criterion_id: 'criterion-1', + verifier_type: 'dev-static', + params: { satisfied: true }, + }], + lockLogic: { type: 'all', criteria: ['criterion-1'] }, + }, +); +assert.deepEqual( + buildCreatorLockPolicy({ criterionId: 'criterion-2', devStaticSatisfied: false }), + { + criteria: [{ + criterion_id: 'criterion-2', + verifier_type: 'dev-static', + params: { satisfied: false }, + }], + lockLogic: { type: 'all', criteria: ['criterion-2'] }, + }, +); + +const creatorPubky = 'pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy'; +const { enforceCreatorIdentityMatch } = await import(pathToFileURL(files.creatorIdentity).href); +let mismatchSignouts = 0; +await assert.rejects( + enforceCreatorIdentityMatch({ + session: { + creatorPubky: () => 'pubkyyyr1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo', + signout: async () => { mismatchSignouts += 1; }, + }, + expectedCreatorPubky: creatorPubky, + }), + /does not match the demo creator/, +); +assert.equal(mismatchSignouts, 1); + +let matchingSignouts = 0; +await enforceCreatorIdentityMatch({ + session: { + creatorPubky: () => creatorPubky, + signout: async () => { matchingSignouts += 1; }, + }, + expectedCreatorPubky: creatorPubky, +}); +assert.equal(matchingSignouts, 0); + +const { invalidateIdentityScopedCreatorState } = await import(pathToFileURL(files.creatorIdentity).href); +const identityScopedState = { + feLockSessionToken: 'old-creator-session', + lockAuthenticated: true, + pendingConnectState: 'old-connect-state', + lockServerOrigin: 'https://locks.example', + lockAuthFrame: {}, +}; +let revokedSession; +const invalidation = await invalidateIdentityScopedCreatorState({ + state: identityScopedState, + revokeSession: async (secret) => { revokedSession = secret; }, +}); +assert.equal(revokedSession, 'old-creator-session'); +assert.equal(invalidation.revoked, true); +assert.deepEqual(identityScopedState, { + feLockSessionToken: null, + lockAuthenticated: false, + pendingConnectState: null, + lockServerOrigin: null, + lockAuthFrame: null, +}); + +const failedRevocationState = { + feLockSessionToken: 'unreachable-session', + lockAuthenticated: true, + pendingConnectState: null, + lockServerOrigin: null, + lockAuthFrame: null, +}; +const failedInvalidation = await invalidateIdentityScopedCreatorState({ + state: failedRevocationState, + revokeSession: async () => { throw new Error('network unavailable'); }, +}); +assert.equal(failedInvalidation.revoked, false); +assert.equal(failedRevocationState.feLockSessionToken, null); +assert.equal(failedRevocationState.lockAuthenticated, false); + +const completeCreatorConnectStart = texts.creator.indexOf('export async function completeCreatorConnect'); +const exchangeCreatorConnectStart = texts.creator.indexOf('export async function exchangeCreatorConnectCode'); +const completeCreatorConnectBody = texts.creator.slice(completeCreatorConnectStart, exchangeCreatorConnectStart); +if (!completeCreatorConnectBody.includes('expectedCreatorPubky') || !completeCreatorConnectBody.includes('expectedCreatorPubky,')) { + throw new Error('completeCreatorConnect must require and forward expectedCreatorPubky'); +} + +assert.deepEqual( + buildCreatorLockPolicy({ + lockType: 'paykit-payment', + criterionId: 'payment-1', + amountSats: '00018446744073709551616', + recipientPubky: creatorPubky, + paykitSetupComplete: true, + }), + { + criteria: [{ + criterion_id: 'payment-1', + verifier_type: 'paykit-payment', + params: { + recipient_pubky: creatorPubky, + amount: '00018446744073709551616', + asset: 'BTC', + }, + }], + lockLogic: { type: 'all', criteria: ['payment-1'] }, + }, +); + +for (const amountSats of ['', '0', '000', '-1', '1.5', '1e3', ' 1', '1 ']) { + assert.throws( + () => buildCreatorLockPolicy({ + lockType: 'paykit-payment', + criterionId: 'payment-1', + amountSats, + recipientPubky: creatorPubky, + paykitSetupComplete: true, + }), + /positive decimal integer string/, + ); +} +for (const amountSats of [1, null, undefined]) { + assert.throws( + () => buildCreatorLockPolicy({ + lockType: 'paykit-payment', + criterionId: 'payment-1', + amountSats, + recipientPubky: creatorPubky, + paykitSetupComplete: true, + }), + /positive decimal integer string/, + ); +} +assert.throws( + () => buildCreatorLockPolicy({ + lockType: 'paykit-payment', + criterionId: 'payment-1', + amountSats: '1', + recipientPubky: creatorPubky, + paykitSetupComplete: false, + }), + /complete Paykit setup/, +); +assert.throws( + () => buildCreatorLockPolicy({ + lockType: 'paykit-payment', + criterionId: 'payment-1', + amountSats: '1', + recipientPubky: '', + paykitSetupComplete: true, + }), + /authenticated creator/, +); + +const { buildPaykitSetupRequest, acceptPaykitSetupEvent } = await import( + pathToFileURL(files.paykitSetup).href +); +const setupRequest = buildPaykitSetupRequest({ + paykitUrl: 'http://localhost:3001', + returnTo: 'http://localhost:8080', + state: 'opaque-setup-state', + xpub: 'must-not-enter-the-iframe-url', +}); +const setupUrl = new URL(setupRequest.url); +assert.equal(setupRequest.origin, 'http://localhost:3001'); +assert.equal(setupUrl.origin, 'http://localhost:3001'); +assert.equal(setupUrl.pathname, '/setup'); +assert.deepEqual([...setupUrl.searchParams.entries()], [ + ['return_to', 'http://localhost:8080'], + ['state', 'opaque-setup-state'], +]); +assert.equal(setupRequest.url.includes('xpub'), false); +assert.equal(setupRequest.url.includes('must-not-enter'), false); +assert.throws(() => buildPaykitSetupRequest({ + paykitUrl: 'http://localhost:3001/setup?xpub=forbidden', + returnTo: 'http://localhost:8080', + state: 'opaque-setup-state', +})); + +const paykitFrameWindow = {}; +const acceptedEvent = { + origin: 'http://localhost:3001', + source: paykitFrameWindow, + data: { type: 'paykit-setup-callback', state: 'opaque-setup-state' }, +}; +const acceptanceContext = { + expectedOrigin: 'http://localhost:3001', + expectedSource: paykitFrameWindow, + expectedState: 'opaque-setup-state', + setupCreator: creatorPubky, + currentCreator: creatorPubky, +}; +assert.deepEqual( + acceptPaykitSetupEvent({ event: acceptedEvent, ...acceptanceContext }), + { status: 'complete' }, +); +assert.deepEqual( + acceptPaykitSetupEvent({ + event: { + ...acceptedEvent, + data: { type: 'paykit-setup-callback', state: 'opaque-setup-state', error: 'setup-failed' }, + }, + ...acceptanceContext, + }), + { status: 'error', error: 'setup-failed' }, +); +for (const event of [ + { ...acceptedEvent, origin: '*' }, + { ...acceptedEvent, origin: 'http://localhost:3002' }, + { ...acceptedEvent, source: {} }, + { ...acceptedEvent, data: { ...acceptedEvent.data, state: 'wrong-state' } }, + { ...acceptedEvent, data: { ...acceptedEvent.data, type: 'unrelated-message' } }, + { ...acceptedEvent, data: { ...acceptedEvent.data, xpub: 'forbidden' } }, + { ...acceptedEvent, data: null }, +]) { + assert.equal(acceptPaykitSetupEvent({ event, ...acceptanceContext }), null); +} +assert.equal( + acceptPaykitSetupEvent({ event: acceptedEvent, ...acceptanceContext, expectedOrigin: '*' }), + null, +); +assert.equal( + acceptPaykitSetupEvent({ event: acceptedEvent, ...acceptanceContext, currentCreator: 'different' }), + null, +); +assert.equal( + acceptPaykitSetupEvent({ event: acceptedEvent, ...acceptanceContext, expectedState: null }), + null, +); +assert.equal( + acceptPaykitSetupEvent({ + event: { ...acceptedEvent, source: null }, + ...acceptanceContext, + expectedSource: null, + }), + null, +); + +const { defaultDemoConfig, validateDemoConfig } = await import(pathToFileURL(files.configLib).href); +const validDemoConfig = structuredClone(defaultDemoConfig); +validDemoConfig.lockServer.pubky = creatorPubky; +assert.equal(validateDemoConfig(validDemoConfig), validDemoConfig); +for (const paykitUrl of [ + '', + 'ftp://localhost:3001', + 'http://user:pass@localhost:3001', + 'http://localhost:3001/setup', + 'http://localhost:3001?query=forbidden', +]) { + const invalidConfig = structuredClone(validDemoConfig); + invalidConfig.paykit.url = paykitUrl; + assert.throws(() => validateDemoConfig(invalidConfig)); +} +const missingPaykitConfig = structuredClone(validDemoConfig); +delete missingPaykitConfig.paykit; +assert.throws(() => validateDemoConfig(missingPaykitConfig)); + +for (const label of ['index', 'iframe']) { + const selectIndex = texts[label].indexOf('id="lock-type"'); + const devStaticIndex = texts[label].indexOf('', selectIndex); + const paymentIndex = texts[label].indexOf('', selectIndex); + if (selectIndex < 0 || devStaticIndex < selectIndex || paymentIndex < devStaticIndex) { + throw new Error(`${label} must default the lock-type selector to dev-static`); + } + if (/<(?:input|select|textarea)[^>]*(?:recipient|asset)/i.test(texts[label])) { + throw new Error(`${label} must not expose editable payment recipient or asset controls`); + } +} + +const publishCallIndex = texts.appIframe.indexOf('await publishLockedContent({'); +const publishCallEndIndex = texts.appIframe.indexOf('});', publishCallIndex); +const publishCall = texts.appIframe.slice(publishCallIndex, publishCallEndIndex); +for (const binding of ['criteria,', 'lockLogic,']) { + if (!publishCall.includes(binding)) { + throw new Error(`creator publishing must pass tested policy binding: ${binding}`); + } +} + +const authStatusRefreshIndex = texts.appIframe.indexOf('async function refreshDemoAuthStatus()'); +const identityResetIndex = texts.appIframe.indexOf( + 'state.paykitSetupComplete = false', + authStatusRefreshIndex, +); +const identityAssignmentIndex = texts.appIframe.indexOf( + 'state.creatorPubky = creatorPubky', + authStatusRefreshIndex, +); +if ( + authStatusRefreshIndex < 0 + || identityResetIndex < 0 + || identityAssignmentIndex < 0 + || identityResetIndex > identityAssignmentIndex +) { + throw new Error('creator identity changes must reset Paykit setup before replacing the identity'); +} + +const lockTypeRefreshIndex = texts.appIframe.indexOf('function refreshLockTypeFields()'); +const lockTypeRefreshEnd = texts.appIframe.indexOf('function startPaykitSetup()', lockTypeRefreshIndex); +const lockTypeRefresh = texts.appIframe.slice(lockTypeRefreshIndex, lockTypeRefreshEnd); +const setupStartIndex = lockTypeRefresh.indexOf('startPaykitSetup()'); +for (const guard of [ + 'if (!paymentSelected)', + 'if (state.paykitSetupComplete)', + 'if (!state.creatorPubky)', + 'if (state.paykitSetupFrame) return', +]) { + const guardIndex = lockTypeRefresh.indexOf(guard); + if (guardIndex < 0 || setupStartIndex < 0 || guardIndex > setupStartIndex) { + throw new Error(`Paykit setup must enforce ${guard} before starting`); + } +} + +const setupStartFunctionIndex = texts.appIframe.indexOf('function startPaykitSetup()'); +const setupStartFunctionEnd = texts.appIframe.indexOf('function refreshLockAuthStatus()', setupStartFunctionIndex); +const setupStartFunction = texts.appIframe.slice(setupStartFunctionIndex, setupStartFunctionEnd); +const setupOpenIndex = setupStartFunction.indexOf('openPaykitSetupIframe(request.url)'); +for (const guard of [ + "el.lockType.value !== 'paykit-payment'", + '|| !state.creatorPubky', + '|| state.paykitSetupComplete', + '|| state.paykitSetupFrame', +]) { + const guardIndex = setupStartFunction.indexOf(guard); + if (guardIndex < 0 || guardIndex > setupOpenIndex) { + throw new Error(`Paykit setup action must enforce ${guard} before iframe navigation`); + } +} +for (const binding of [ + 'state.pendingPaykitSetupState = pendingState', + 'state.paykitSetupOrigin = request.origin', + 'state.paykitSetupCreator = state.creatorPubky', +]) { + const bindingIndex = setupStartFunction.indexOf(binding); + if (bindingIndex < 0 || setupOpenIndex < 0 || bindingIndex > setupOpenIndex) { + throw new Error(`Paykit setup must bind ${binding} before iframe navigation`); + } +} + +const setupAcceptanceIndex = texts.appIframe.indexOf('const result = acceptPaykitSetupEvent({'); +const setupAcceptedGuardIndex = texts.appIframe.indexOf('if (!result) return;', setupAcceptanceIndex); +const setupCompleteIndex = texts.appIframe.indexOf('state.paykitSetupComplete = true', setupAcceptanceIndex); +if ( + setupAcceptanceIndex < 0 + || setupAcceptedGuardIndex < setupAcceptanceIndex + || setupCompleteIndex < setupAcceptedGuardIndex +) { + throw new Error('Paykit setup must mark completion only after exact callback acceptance'); +} + +const setupCloseIndex = texts.appIframe.indexOf('function closePaykitSetupIframe()'); +const setupCloseEnd = texts.appIframe.indexOf('function showLockAuthComplete()', setupCloseIndex); +const setupCloseFunction = texts.appIframe.slice(setupCloseIndex, setupCloseEnd); +for (const clearedBinding of [ + 'state.pendingPaykitSetupState = null', + 'state.paykitSetupOrigin = null', + 'state.paykitSetupFrame = null', + 'state.paykitSetupCreator = null', +]) { + if (!setupCloseFunction.includes(clearedBinding)) { + throw new Error(`closing Paykit setup must clear ${clearedBinding}`); + } +} + +for (const label of ['appIframe', 'index', 'iframe', 'paykitSetup']) { + if (/xpub/i.test(texts[label])) { + throw new Error(`${label} must not receive, render, store, or log xpub material`); + } +} +if (/localStorage\.(?:getItem|setItem)\([^)]*paykit/i.test(texts.appIframe)) { + throw new Error('Paykit setup state must remain in memory only'); +} +const setupCallbackEnd = texts.appIframe.indexOf('// Open the Lock Server /connect page', setupAcceptanceIndex); +const setupCallback = texts.appIframe.slice(setupAcceptanceIndex, setupCallbackEnd); +if (setupCallback.includes('postClientLog(') || setupStartFunction.includes('postClientLog(')) { + throw new Error('Paykit setup URL and callback data must not enter client logs'); +} + +for (const [label, snippets] of Object.entries({ + app: ['window.location.assign', 'completeCreatorConnect'], + appIframe: ['state.lastReceivedCode', 'feLockSessionToken: ${state.feLockSessionToken}', "'lock-auth-iframe-complete', { code }", 'xpub', 'account_xpub'], + readme: ['redirect to the Lock-Server-hosted `/connect` shell', 'stores the Locks frontend session in `localStorage`', 'verifier dropdown has one option'], + index: ['full-page redirect', 'Switch to iframe flow', 'id="paykit-recipient', 'id="paykit-asset', 'xpub', 'account_xpub'], + iframe: ['full-page redirect', 'Switch to redirect flow', 'id="paykit-recipient', 'id="paykit-asset', 'xpub', 'account_xpub'], + flows: ['Redirect flow', 'full-page redirect', 'localStorage'], +})) { + for (const snippet of snippets) { + if (texts[label].includes(snippet)) { + throw new Error(`${label} contains forbidden auth snippet: ${snippet}`); + } + } +} + +const stateGuardIndex = texts.creator.indexOf('if (state !== expectedState)'); +const codeExchangeIndex = texts.creator.indexOf('locks.exchangeFrontendSessionCode('); +if (stateGuardIndex < 0 || codeExchangeIndex < 0 || stateGuardIndex > codeExchangeIndex) { + throw new Error('creator must reject mismatched connect state before exchanging the one-time code'); +} + +const callbackExchangeIndex = texts.appIframe.indexOf('await exchangeCreatorConnectCode({'); +const callbackExchangeEndIndex = texts.appIframe.indexOf('});', callbackExchangeIndex); +const callbackExchangeCall = texts.appIframe.slice(callbackExchangeIndex, callbackExchangeEndIndex); +for (const binding of ['state: receivedState', 'expectedState: state.pendingConnectState']) { + if (!callbackExchangeCall.includes(binding)) { + throw new Error(`iframe callback exchange must include ${binding}`); + } +} +for (const guard of [ + 'event.origin !== state.lockServerOrigin', + 'event.source !== state.lockAuthFrame?.contentWindow', +]) { + const guardIndex = texts.appIframe.indexOf(guard); + if (guardIndex < 0 || callbackExchangeIndex < 0 || guardIndex > callbackExchangeIndex) { + throw new Error(`iframe callback must enforce ${guard} before exchanging the one-time code`); + } +} + +const { + buildPaykitPaymentProofBundle, + classifyPaymentLifecycle, + createLatestRequestGate, + decodeGuardedContentResponse, + parsePreparedReaderBrowserStatus, + selectCurrentPaykitPaymentRequest, + workflowHandleMatches, +} = await import(pathToFileURL(files.readerFlow).href); +const paymentResource = 'pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy/pub/locks.app/000G40R40M30E209185GR38E1W8124GK2GAHC5RR34D1P70X3RFG.json'; +const paymentBundleId = '000G40R40M30E209185GR38E1W'; +const readerPublicKey = 'pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo'; +assert.deepEqual(buildPaykitPaymentProofBundle({ + resource: paymentResource, + readerPublicKey, + criterionId: 'payment-criterion', + bundleId: paymentBundleId, +}), { + version: 1, + bundle_id: paymentBundleId, + pubky_lock_resource: paymentResource, + reader_public_key: readerPublicKey, + proofs: [{ + criterion_id: 'payment-criterion', + verifier_type: 'paykit-payment', + payload: {}, + }], +}); +assert.equal(classifyPaymentLifecycle({ status: 'pending' }), 'retry'); +assert.equal(classifyPaymentLifecycle({ status: 'in_progress' }), 'retry'); +assert.equal(classifyPaymentLifecycle({ status: 'completed' }), 'completed'); +assert.equal(classifyPaymentLifecycle({ status: 'failed' }), 'failed'); +assert.equal(classifyPaymentLifecycle({ status: 'expired' }), 'failed'); +assert.throws(() => classifyPaymentLifecycle({ status: 'unknown' }), /unknown lifecycle status/); +const workflowHandle = { incarnation: 7, resource: paymentResource, creator: creatorPubky, bundleId: paymentBundleId }; +const currentWorkflow = { ...workflowHandle }; +assert.equal(workflowHandleMatches(workflowHandle, currentWorkflow), true); +for (const staleWorkflow of [ + { ...currentWorkflow, incarnation: 8 }, + { ...currentWorkflow, resource: `${paymentResource}.changed` }, + { ...currentWorkflow, creator: readerPublicKey }, + { ...currentWorkflow, bundleId: `${paymentBundleId}0` }, +]) { + assert.equal(workflowHandleMatches(workflowHandle, staleWorkflow), false); +} +assert.equal( + workflowHandleMatches( + { incarnation: 7, resource: paymentResource }, + currentWorkflow, + ), + true, +); +const latestRequestGate = createLatestRequestGate(); +const olderStatusRequest = latestRequestGate.begin(7); +const newerStatusRequest = latestRequestGate.begin(7); +assert.equal(latestRequestGate.isCurrent(olderStatusRequest, 7), false); +assert.equal(latestRequestGate.isCurrent(newerStatusRequest, 7), true); +assert.equal(latestRequestGate.isCurrent(newerStatusRequest, 8), false); +latestRequestGate.finish(newerStatusRequest); +assert.equal(latestRequestGate.isCurrent(newerStatusRequest, 7), false); +const invalidatedStatusRequest = latestRequestGate.begin(8); +latestRequestGate.invalidate(); +assert.equal(latestRequestGate.isCurrent(invalidatedStatusRequest, 8), false); +const priorPaymentRequest = { + state: 'request_received', + payment_request_id: '12345678-1234-4123-8123-123456789abc', +}; +const newerPaymentRequest = { + state: 'request_received', + payment_request_id: 'abcdef12-3456-4789-8123-123456789abc', +}; +assert.equal(selectCurrentPaykitPaymentRequest({ + status: priorPaymentRequest, + baselinePaymentRequestId: priorPaymentRequest.payment_request_id, + currentPaymentRequest: null, +}), null); +assert.equal(selectCurrentPaykitPaymentRequest({ + status: priorPaymentRequest, + baselinePaymentRequestId: priorPaymentRequest.payment_request_id, + currentPaymentRequest: newerPaymentRequest, +}), newerPaymentRequest); +assert.equal(selectCurrentPaykitPaymentRequest({ + status: newerPaymentRequest, + baselinePaymentRequestId: priorPaymentRequest.payment_request_id, + currentPaymentRequest: null, +}), newerPaymentRequest); +assert.equal(selectCurrentPaykitPaymentRequest({ + status: newerPaymentRequest, + baselinePaymentRequestId: priorPaymentRequest.payment_request_id, + currentPaymentRequest: newerPaymentRequest, +}), newerPaymentRequest); +assert.deepEqual(parsePreparedReaderBrowserStatus({ + version: 1, + prepared: true, + reader_pubky: readerPublicKey, +}), { + version: 1, + prepared: true, + reader_pubky: readerPublicKey, +}); +assert.deepEqual(parsePreparedReaderBrowserStatus({ version: 1, prepared: false }), { + version: 1, + prepared: false, +}); +for (const invalidStatus of [ + { version: 1, prepared: true }, + { version: 1, prepared: false, reader_pubky: readerPublicKey }, + { version: 2, prepared: false }, + { version: 1, prepared: true, reader_pubky: `${readerPublicKey}x` }, +]) { + assert.throws(() => parsePreparedReaderBrowserStatus(invalidStatus), /invalid prepared Paykit reader status/); +} +const guardedResponse = new Response(new TextEncoder().encode('payment unlocked'), { + headers: { 'content-type': 'text/plain; charset=utf-8' }, +}); +const decodedGuarded = await decodeGuardedContentResponse(guardedResponse); +assert.equal(decodedGuarded.contentType, 'text/plain; charset=utf-8'); +assert.equal(decodedGuarded.kind, 'text'); +assert.equal(decodedGuarded.text, 'payment unlocked'); +assert.equal(decodedGuarded.size, 16); +const decodedImage = await decodeGuardedContentResponse(new Response(Uint8Array.of(1, 2, 3), { + headers: { 'content-type': 'image/png' }, +})); +assert.equal(decodedImage.kind, 'image'); +assert.equal(decodedImage.text, null); +assert.deepEqual([...decodedImage.bytes], [1, 2, 3]); + +const { + buildReaderHelperInput, + parseReaderHelperSuccess, + requireReaderEnvironment, + runReaderOperation, + signupReaderBestEffort, +} = await import(pathToFileURL(files.paykitReaderLib).href); +const { + buildPreparedReaderBrowserStatus, + readPreparedReaderStatus, + validatePreparedReaderStatus, + writePreparedReaderStatus, +} = await import(pathToFileURL(files.paykitReaderStatus).href); +const { main: preparePaykitReaderMain } = await import(pathToFileURL(files.preparePaykitReader).href); +const readerSecret = Uint8Array.from({ length: 32 }, (_, index) => index + 1); +const prepareInput = buildReaderHelperInput({ operation: 'prepare', readerSecret }); +assert.deepEqual(Object.keys(prepareInput), ['version', 'operation', 'reader_secret']); +assert.equal(prepareInput.version, 1); +assert.equal(prepareInput.operation, 'prepare'); +assert.equal(Buffer.from(prepareInput.reader_secret, 'base64url').length, 32); +assert.deepEqual(parseReaderHelperSuccess({ + operation: 'prepare', + stdout: '{"version":1,"status":"prepared","reader_pubky":"pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo","receiver_path":"bitkit/wallet"}\n', +}), { + version: 1, + status: 'prepared', + reader_pubky: readerPublicKey, + receiver_path: 'bitkit/wallet', +}); +const receivedOutput = { + version: 1, + status: 'received', + payment_request_id: 'b7f9c2a1-6d43-4b0e-a8d4-0fe2c712ab33', + address: 'bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqdku202', + asset: 'BTC', + amount_sats: '50000', + payment_command: "docker compose exec -T bitcoin sh -ec 'bitcoin-cli -conf=\"$BITCOIN_DATA/bitcoin.conf\" -regtest -rpcwallet=miner sendtoaddress \"bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqdku202\" \"0.00050000\"'", + optional_mining_command: "docker compose exec -T bitcoin sh -ec 'bitcoin-cli -conf=\"$BITCOIN_DATA/bitcoin.conf\" -regtest -rpcwallet=miner generatetoaddress 6 \"$(bitcoin-cli -conf=\"$BITCOIN_DATA/bitcoin.conf\" -regtest -rpcwallet=miner getnewaddress)\"'", +}; +const operatorReceivedOutput = { + ...receivedOutput, + 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/creator.rs b/locks-sdk/bindings/js/src/creator.rs index be56bae..fc1900f 100644 --- a/locks-sdk/bindings/js/src/creator.rs +++ b/locks-sdk/bindings/js/src/creator.rs @@ -213,6 +213,14 @@ impl CreateContentLockRequestBuilder { .criteria .as_ref() .ok_or_else(|| "content lock request requires criteria".to_owned())?; + let typed_criteria: Vec = + serde_json::from_value(criteria.clone()) + .map_err(|err| format!("invalid content lock criteria: {err}"))?; + for criterion in &typed_criteria { + criterion + .validate_params() + .map_err(|err| format!("invalid content lock criterion: {err}"))?; + } body.insert("criteria".to_owned(), criteria.clone()); let lock_logic = state .lock_logic @@ -619,6 +627,31 @@ mod tests { assert!(format!("{err:?}").contains("criteria")); } + #[test] + fn create_content_lock_request_builder_rejects_invalid_paykit_payment_in() { + let builder = complete_builder(); + builder.state.borrow_mut().primary_resource = + Some(resource("/priv/locks.app/content/example.txt", "hash", 13)); + builder.state.borrow_mut().criteria = Some(serde_json::json!([{ + "criterion_id": "payment", + "verifier_type": "paykit-payment", + "params": { + "recipient_pubky": "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy", + "amount": "50000", + "asset": "BTC", + "payment_in": 0 + } + }])); + builder.state.borrow_mut().lock_logic = Some(serde_json::json!({ + "type": "all", + "criteria": ["payment"] + })); + + let err = builder.build_value().unwrap_err(); + + assert!(err.contains("payment_in")); + } + #[test] fn create_content_lock_request_builder_rejects_duplicate_secondary_path() { let builder = complete_builder(); diff --git a/locks-sdk/bindings/js/src/locks.rs b/locks-sdk/bindings/js/src/locks.rs index 3788884..4c3f7b2 100644 --- a/locks-sdk/bindings/js/src/locks.rs +++ b/locks-sdk/bindings/js/src/locks.rs @@ -343,11 +343,12 @@ impl Locks { .prepare_exchange_request_with_pkarr_resolver(&request, &resolver, None) .await .map_err(|err| invalid_input(err.to_string()))?; - let token = post_json_for_session_token(&request).await?; - Ok(Session::new( - self.inner.restore_session(token), + let response = post_json_for_session(&request).await?; + Ok(Session::new_with_creator( + self.inner.restore_session(&response.session_token), self.inner.clone(), self.options.clone(), + Some(response.creator.to_string()), )) } } @@ -684,7 +685,9 @@ async fn fetch_content_lock_json(request_plan: &JsPreparedContentLockRequest) -> } #[cfg(target_arch = "wasm32")] -async fn post_json_for_session_token(request_plan: &JsPreparedRequest) -> JsResult { +async fn post_json_for_session( + request_plan: &JsPreparedRequest, +) -> JsResult { let request_init = web_sys::RequestInit::new(); request_init.set_method(request_plan.method); request_init.set_mode(web_sys::RequestMode::Cors); @@ -725,17 +728,68 @@ async fn post_json_for_session_token(request_plan: &JsPreparedRequest) -> JsResu .map_err(|err| invalid_input(format!("failed to parse JSON response: {err:?}")))?; let value: Value = serde_wasm_bindgen::from_value(json) .map_err(|err| invalid_input(format!("invalid session response JSON: {err}")))?; - value + parse_frontend_session_response(value).map_err(invalid_input) +} + +#[cfg(any(test, target_arch = "wasm32"))] +#[derive(Debug, Clone, PartialEq, Eq)] +struct FrontendSessionResponse { + session_token: String, + creator: CreatorPubky, +} + +#[cfg(any(test, target_arch = "wasm32"))] +fn parse_frontend_session_response(value: Value) -> Result { + let session_token = value .get("session_token") .and_then(Value::as_str) .map(ToOwned::to_owned) - .ok_or_else(|| invalid_input("frontend session response missing session_token")) + .ok_or_else(|| "frontend session response missing session_token".to_owned())?; + let creator = value + .get("creator") + .and_then(Value::as_str) + .ok_or_else(|| "frontend session response missing creator".to_owned()) + .and_then(|creator| { + CreatorPubky::from_str(creator) + .map_err(|_| "frontend session response contains invalid creator".to_owned()) + })?; + Ok(FrontendSessionResponse { + session_token, + creator, + }) } #[cfg(test)] mod tests { use super::*; + #[test] + fn frontend_session_response_preserves_authenticated_creator() { + let response = parse_frontend_session_response(serde_json::json!({ + "session_token": "frontend-session-secret", + "creator": "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy", + "expires_at": "2030-01-01T00:00:00Z" + })) + .unwrap(); + + assert_eq!(response.session_token, "frontend-session-secret"); + assert_eq!( + response.creator.to_string(), + "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy" + ); + } + + #[test] + fn frontend_session_response_rejects_missing_creator() { + let error = parse_frontend_session_response(serde_json::json!({ + "session_token": "frontend-session-secret", + "expires_at": "2030-01-01T00:00:00Z" + })) + .unwrap_err(); + + assert!(error.contains("missing creator")); + } + #[test] fn locks_constructor_is_available_for_valid_lock_server_pubky() { let locks = diff --git a/locks-sdk/bindings/js/src/session.rs b/locks-sdk/bindings/js/src/session.rs index ac68fb7..575adad 100644 --- a/locks-sdk/bindings/js/src/session.rs +++ b/locks-sdk/bindings/js/src/session.rs @@ -240,6 +240,7 @@ impl JsAuthorizedRequestPlan { pub struct Session { inner: locks_sdk::LocksSession, client: locks_sdk::LocksClient, + creator_pubky: Option, #[cfg_attr(not(any(test, target_arch = "wasm32")), allow(dead_code))] options: LocksOptions, } @@ -256,6 +257,11 @@ impl Session { self.client.lock_server().to_string() } + #[wasm_bindgen(js_name = creatorPubky)] + pub fn creator_pubky(&self) -> Option { + self.creator_pubky.clone() + } + #[wasm_bindgen(getter)] pub fn creator(&self) -> Creator { Creator::new(self.clone()) @@ -279,10 +285,20 @@ impl Session { inner: locks_sdk::LocksSession, client: locks_sdk::LocksClient, options: LocksOptions, + ) -> Self { + Self::new_with_creator(inner, client, options, None) + } + + pub(crate) fn new_with_creator( + inner: locks_sdk::LocksSession, + client: locks_sdk::LocksClient, + options: LocksOptions, + creator_pubky: Option, ) -> Self { Self { inner, client, + creator_pubky, options, } } @@ -510,6 +526,20 @@ mod tests { ); } + #[test] + fn exchanged_session_exposes_authenticated_creator_pubky() { + let client = test_client(); + let creator = "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy"; + let session = Session::new_with_creator( + client.restore_session("frontend-session-secret"), + client, + LocksOptions::new(), + Some(creator.to_owned()), + ); + + assert_eq!(session.creator_pubky(), Some(creator.to_owned())); + } + #[test] fn signout_request_uses_current_frontend_session_endpoint_and_bearer() { let client = test_client(); diff --git a/locks-sdk/bindings/js/src/viewer.rs b/locks-sdk/bindings/js/src/viewer.rs index e7afb7d..a9cab03 100644 --- a/locks-sdk/bindings/js/src/viewer.rs +++ b/locks-sdk/bindings/js/src/viewer.rs @@ -210,6 +210,23 @@ impl Viewer { .map_err(|err| invalid_input(err.to_string()))?; fetch_viewer_bytes(&request).await } + + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen(js_name = proxyReadGuardedResourceResponse)] + pub async fn proxy_read_guarded_resource_response( + &self, + access_credential: &str, + path: String, + ) -> JsResult { + let resolver = BrowserPkarrResolver::new_with_options(&self.options) + .map_err(|err| invalid_input(err.to_string()))?; + let request = self + .build_proxy_read_guarded_resource_request(access_credential, path) + .prepare_with_pkarr_resolver(&resolver, None) + .await + .map_err(|err| invalid_input(err.to_string()))?; + fetch_viewer_response(&request).await + } } impl Viewer { @@ -408,13 +425,8 @@ async fn fetch_viewer_json_value(request: &JsPreparedViewerRequest) -> JsResult< #[cfg(target_arch = "wasm32")] async fn fetch_viewer_bytes(request: &JsPreparedViewerRequest) -> JsResult { - let response = fetch_viewer(request).await?; - if !response.ok() { - return Err(invalid_input(format!( - "Lock Server viewer request failed with HTTP {}", - response.status() - ))); - } + let response = fetch_viewer_response(request).await?; + let buffer = wasm_bindgen_futures::JsFuture::from( response .array_buffer() @@ -425,6 +437,18 @@ async fn fetch_viewer_bytes(request: &JsPreparedViewerRequest) -> JsResult JsResult { + let response = fetch_viewer(request).await?; + if !response.ok() { + return Err(invalid_input(format!( + "Lock Server viewer request failed with HTTP {}", + response.status() + ))); + } + Ok(response) +} + #[cfg(target_arch = "wasm32")] async fn fetch_viewer(request: &JsPreparedViewerRequest) -> JsResult { use wasm_bindgen::JsCast; diff --git a/locks-sdk/src/transport.rs b/locks-sdk/src/transport.rs index 67f6b42..785cfc6 100644 --- a/locks-sdk/src/transport.rs +++ b/locks-sdk/src/transport.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::net::IpAddr; use crate::error::{LocksSdkError, Result}; use url::Url; @@ -51,7 +52,11 @@ fn apply_endpoint_to_url( .domain .as_deref() .ok_or(LocksSdkError::MissingBrowserEndpointDomain)?; - let is_testnet_domain = domain == "localhost" || testnet_host == Some(domain); + let is_testnet_domain = domain == "localhost" + || domain + .parse::() + .is_ok_and(|address| address.is_loopback()) + || testnet_host == Some(domain); if is_testnet_domain { url.set_scheme("http") @@ -171,6 +176,26 @@ mod tests { assert_eq!(request.url.as_str(), "http://localhost:55433/connect"); } + #[test] + fn browser_request_rewrite_uses_http_port_for_loopback_ip_endpoint() { + let mut params = BTreeMap::new(); + params.insert(HTTP_PORT_PARAM, 55433); + let endpoint = BrowserEndpoint { + domain: Some("127.0.0.1".to_owned()), + port: Some(443), + params, + }; + + let request = rewrite_browser_request( + "https://_pubky.pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo/connect", + &endpoint, + None, + ) + .unwrap(); + + assert_eq!(request.url.as_str(), "http://127.0.0.1:55433/connect"); + } + #[test] fn browser_request_rewrite_requires_http_port_for_localhost_endpoint() { let endpoint = BrowserEndpoint { diff --git a/locks-server/src/api/creator_authority.rs b/locks-server/src/api/creator_authority.rs index 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#"