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