diff --git a/README.md b/README.md index e0c6b1b..c0ba611 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ 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: @@ -65,8 +65,8 @@ 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 +Paykit Server at and publishes the reader at +. Pubky Testnet is built from `pubky/pubky-core` source at commit `75eb1324f86e8caa16c41f18a2cd6b8e1909ee7b`, not from a released Pubky image or version. Payment remains a manual operator action. diff --git a/compose.paykit-local-demo.yaml b/compose.paykit-local-demo.yaml index 0d5ac90..97391ae 100644 --- a/compose.paykit-local-demo.yaml +++ b/compose.paykit-local-demo.yaml @@ -26,7 +26,8 @@ services: .local/pubky-homeserver \ .local/locks-server \ .local/paykit-server \ - .local/paykit-config + .local/paykit-config \ + .local/homegate-bridge chmod 0600 .local/paykit-server/paykit.env volumes: - ./examples/js-sdk:/workspace/examples/js-sdk:ro @@ -141,6 +142,8 @@ services: depends_on: bitcoin-bootstrap: condition: service_completed_successfully + ports: + - "127.0.0.1:${LOCKS_ELECTRUM_PORT:-60001}:50001" electrum-readiness: image: node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 @@ -165,7 +168,6 @@ services: - "127.0.0.1:${LOCKS_HTTP_RELAY_PORT:-15412}:15412" - "127.0.0.1:${LOCKS_HOMESERVER_HTTP_PORT:-6286}:6286" - "127.0.0.1:${LOCKS_HOMESERVER_PUBKY_PORT:-6287}:6287" - - "127.0.0.1:${LOCKS_HOMESERVER_ADMIN_PORT:-6288}:6288" - "127.0.0.1:${LOCKS_SERVER_PORT:-3000}:3000" - "127.0.0.1:${LOCKS_PAYKIT_PORT:-3001}:3001" - "127.0.0.1:${LOCKS_CREATOR_DEMO_PORT:-8080}:8080" @@ -177,6 +179,38 @@ services: postgres: condition: service_healthy + homegate-bridge: + image: node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 + user: "1000:1000" + working_dir: /workspace + command: + - /bin/sh + - -euc + - | + set -a + . /run/compose-local/homegate-bridge/homegate.env + set +a + exec node examples/js-sdk/scripts/homegate-bridge.mjs + environment: + HOMEGATE_BRIDGE_CONFIG: /run/compose-local/demo-config/config.json + HOMEGATE_BRIDGE_HOMESERVER_ADMIN_URL: http://pubky-testnet:6288 + ports: + - "127.0.0.1:${LOCKS_HOMEGATE_PORT:-6288}:8082" + volumes: + - ./examples/js-sdk:/workspace/examples/js-sdk:ro + - ./.local/demo-config:/run/compose-local/demo-config:ro + - ./.local/homegate-bridge:/run/compose-local/homegate-bridge:ro + depends_on: + pubky-testnet: + condition: service_started + demo-config: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8082/health').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1))"] + interval: 2s + timeout: 2s + retries: 30 + locks-server: build: context: . @@ -247,11 +281,11 @@ services: paykit-server: image: pubky-locks-paykit-server:local build: - context: "https://github.com/pubky/paykit-server.git#f38c7915e6b9b104e040773e78438f8aa984c46c" + context: "https://github.com/pubky/paykit-server.git#5ed3e8e849a16045c26c37a75068625dda333785" dockerfile: Dockerfile.local additional_contexts: - paykit-lib: "https://github.com/pubky/paykit-rs.git#52a852995bfc457b78d32f5a45f6741766a89bba:paykit-lib" - paykit-sdk: "https://github.com/pubky/paykit-rs.git#52a852995bfc457b78d32f5a45f6741766a89bba:paykit-sdk" + paykit-lib: "https://github.com/pubky/paykit-rs.git#6b241878a9bba5cecea919c0298c3f90624be6ff:paykit-lib" + paykit-sdk: "https://github.com/pubky/paykit-rs.git#6b241878a9bba5cecea919c0298c3f90624be6ff:paykit-sdk" locks: "https://github.com/pubky/locks.git#df5ea1b6d8dcdec3a9b5a915c3f57bca69d75c8a" depends_on: @@ -320,19 +354,16 @@ services: PAYKIT_COMPANION_AUTH_BIN: /usr/local/bin/paykit-companion-auth PUBKY_LOCK_DEBUG: ${PUBKY_LOCK_DEBUG:-0} volumes: - - ./locks-sdk/bindings/js/pkg:/workspace/locks-sdk/bindings/js/pkg:ro - ./.local/demo-config:/workspace/.local/demo-config:ro - ./.local/js-sdk-demo:/workspace/.local/js-sdk-demo - - ./.local/content-creator:/workspace/.local/content-creator - ./.local/creator-public:/workspace/.local/creator-public command: - sh - -lc - | set -eu - npm --prefix examples/js-sdk run create-user -- --role content-creator - npm --prefix examples/js-sdk run publish-creator-profile - npm --prefix examples/js-sdk run start-server -- --allow-unhealthy + rm -f /workspace/.local/creator-public/profile.json + npm --prefix examples/js-sdk run start-server -- --external-wallet reader-demo: restart: unless-stopped @@ -363,9 +394,9 @@ services: PAYKIT_READER_RECEIVER_PATH: bitkit/wallet PAYKIT_READER_SERVER_PATH: bitkit/server PAYKIT_READER_WORKER_ENABLED: "1" + PAYKIT_EXTERNAL_READER_PUBKY: ${PAYKIT_EXTERNAL_READER_PUBKY:-} PUBKY_LOCK_DEBUG: ${PUBKY_LOCK_DEBUG:-0} volumes: - - ./locks-sdk/bindings/js/pkg:/workspace/locks-sdk/bindings/js/pkg:ro - ./.local/demo-config:/workspace/.local/demo-config:ro - ./.local/creator-public:/workspace/.local/creator-public:ro - ./.local/content-viewer:/workspace/.local/content-viewer @@ -374,8 +405,10 @@ services: - sh - -euc - | - npm --prefix examples/js-sdk run create-user -- --role content-viewer - exec node examples/js-sdk/scripts/start-reader-demo-server.mjs --allow-unhealthy + if [ -z "$PAYKIT_EXTERNAL_READER_PUBKY" ]; then + npm --prefix examples/js-sdk run create-user -- --role content-viewer + fi + exec node examples/js-sdk/scripts/start-reader-demo-server.mjs healthcheck: test: - CMD diff --git a/docker/js-demo.Dockerfile b/docker/js-demo.Dockerfile index c2d6ce6..3eb9440 100644 --- a/docker/js-demo.Dockerfile +++ b/docker/js-demo.Dockerfile @@ -1,6 +1,16 @@ # syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e FROM paykit-runtime AS paykit-runtime +FROM rust:1.91.1-slim-bookworm@sha256:8514999d4786ef12efe89239e86b3d0a021b94b9d35108c8efe6c79ca7dc1a65 AS locks-sdk-wasm +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential ca-certificates libssl-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* +RUN rustup target add wasm32-unknown-unknown \ + && cargo install wasm-pack --version 0.13.1 --locked +WORKDIR /workspace +COPY . . +RUN cd locks-sdk/bindings/js && wasm-pack build --target web --out-dir pkg + FROM node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 WORKDIR /workspace RUN apt-get update \ @@ -10,8 +20,9 @@ COPY --chown=node:node examples/js-sdk/package.json examples/js-sdk/package-lock RUN npm --prefix examples/js-sdk ci --ignore-scripts \ && npm cache clean --force COPY --chown=node:node examples/js-sdk /workspace/examples/js-sdk +COPY --from=locks-sdk-wasm --chown=node:node /workspace/locks-sdk/bindings/js/pkg /workspace/locks-sdk/bindings/js/pkg COPY --from=paykit-runtime /usr/local/bin/paykit-companion-auth /usr/local/bin/paykit-companion-auth COPY --from=paykit-runtime /usr/local/bin/paykit-reader-demo /usr/local/bin/paykit-reader-demo -RUN mkdir -p /workspace/locks-sdk/bindings/js/pkg /workspace/.local \ +RUN mkdir -p /workspace/.local \ && chown -R node:node /workspace USER node:node diff --git a/docker/locks-server-compose-entrypoint.sh b/docker/locks-server-compose-entrypoint.sh index c7b015c..e114aec 100644 --- a/docker/locks-server-compose-entrypoint.sh +++ b/docker/locks-server-compose-entrypoint.sh @@ -86,7 +86,7 @@ frontend_session_ttl_seconds = 86400 frontend_session_code_ttl_seconds = 120 [creator_authority_acquisition.legacy_connect] -allowed_return_origins = ["http://localhost:8080"] +allowed_return_origins = ["http://127.0.0.1:8080", "http://localhost:8080"] [secrets] creator_authority_key_env = "PUBKY_LOCK_CREATOR_AUTH_ENCRYPTION_KEY" @@ -101,8 +101,8 @@ network = "testnet" public_ip = "127.0.0.1" public_pubky_tls_port = 6287 public_icann_http_port = 3000 -icann_domain = "localhost" -pkarr_relays = ["http://localhost:15411"] +icann_domain = "127.0.0.1" +pkarr_relays = ["http://127.0.0.1:15411"] key_republisher_interval_seconds = 86400 [rate_limits.verification_submission] diff --git a/examples/js-sdk/README.md b/examples/js-sdk/README.md index a019561..b50e9dd 100644 --- a/examples/js-sdk/README.md +++ b/examples/js-sdk/README.md @@ -15,6 +15,7 @@ examples/js-sdk/reader.html examples/js-sdk/reader-app.js examples/js-sdk/reader-flow.js examples/js-sdk/scripts/init-config.mjs +examples/js-sdk/scripts/homegate-bridge.mjs examples/js-sdk/scripts/create-user.mjs examples/js-sdk/scripts/authenticate.mjs examples/js-sdk/scripts/prepare-paykit-reader.mjs @@ -61,7 +62,7 @@ Required tools/services: DHT bootstrap localhost:6881 ``` -Build the local WASM SDK package first: +For direct npm development, build the local WASM SDK package first: ```bash npm --prefix locks-sdk/bindings/js run build @@ -83,6 +84,8 @@ The examples package uses `@synonymdev/pubky` for Node-side Pubky testnet auth/k The supported end-to-end path is the complete local Compose stack documented below. It generates ignored owner-only credentials, starts both databases and both application servers, bootstraps Bitcoin regtest, waits for Fulcrum using `server.version`, and starts the creator and reader demos. +The Compose image builds the JS/WASM package itself. A fresh checkout does not need a host-generated `locks-sdk/bindings/js/pkg` directory. + For direct npm development without Compose, provide a running local Pubky testnet, PostgreSQL, Lock Server, and Paykit Server first. `init-config` reads the Lock Server public key from `~/.pubky-lock/config.toml` by default; it does not read the Lock Server signing secret. ### Basic Locks stack @@ -190,7 +193,7 @@ checkout is required. `compose.paykit-local-demo.yaml` is intentionally limited to local development and demonstration. When `.local` is absent, the one-shot `compose-bootstrap` service creates the ignored owner-only credentials and non-state configuration before dependent services start. Existing generated credentials are validated and reused. For a quiet configuration check without printing generated environment values, run `npm --prefix examples/js-sdk run validate:paykit-compose`; the wrapper inspects a captured `docker compose -f compose.paykit-local-demo.yaml config --no-env-resolution` model. -This starts separate Locks and Paykit PostgreSQL services, Bitcoin Core regtest, a 101-block wallet bootstrap, Fulcrum readiness through `server.version`, Pubky testnet, Locks, Paykit Server, and both browser demos. All published ports bind to host loopback. Paykit is browser-visible at `http://localhost:3001`; Locks reaches it at `http://127.0.0.1:3001` inside the shared Pubky network namespace. The unprivileged creator and reader images contain the reviewed native helpers at `/usr/local/bin`; they receive only their explicit role/runtime directories and generated WASM package, never the repository root or Lock Server identity volume. +This starts separate Locks and Paykit PostgreSQL services, Bitcoin Core regtest, a 101-block wallet bootstrap, Fulcrum readiness through `server.version`, Pubky testnet, a local Homegate-compatible signup bridge, Locks, Paykit Server, and both browser demos. All published ports bind to host loopback. Paykit is browser-visible at `http://localhost:3001`, the Homegate bridge at `http://localhost:6288`, and Fulcrum at `tcp://localhost:60001`. Locks reaches Paykit at `http://127.0.0.1:3001` inside the shared Pubky network namespace. The unprivileged creator and reader images contain the reviewed native helpers and a package built in the image; they receive only their explicit runtime directories, never the repository root or Lock Server identity volume. Open: @@ -265,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 -- \ @@ -279,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 @@ -297,7 +302,7 @@ The shell returns `{ state, code }` directly to the parent with `postMessage`. T http://localhost:8080/auth/lock-server/callback ``` -Approve the Lock Server auth string with the same role: +Approve the Lock Server auth string with the same identity. In Compose, scan or paste it into the same external wallet. For direct npm development, use: ```bash npm --prefix examples/js-sdk run authenticate -- \ @@ -338,7 +343,7 @@ The Paykit iframe displays the auth URL and both approved local commands. First npm --prefix examples/js-sdk run generate-paykit-account-tpub ``` -This command uses the running Compose regtest node, requests public descriptors only, selects `m/84'/1'/0'`, and intentionally prints only the account-level `tpub` and index at this explicit setup boundary. It never prints or exports the account private key. Then run the companion-auth wrapper: +This command uses the running Compose regtest node, requests public descriptors only, selects `m/84'/1'/0'`, and intentionally prints only the account-level `tpub` and index at this explicit setup boundary. It never prints or exports the account private key. In the external-wallet flow, scan or paste the Paykit authorization request into the same wallet. For direct npm development with a generated creator recovery file, the companion-auth wrapper remains available: ```bash docker compose -f compose.paykit-local-demo.yaml exec creator-demo npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator @@ -389,7 +394,7 @@ The worker is the sole mutable owner of `./.local/paykit-reader/state.v1`. A dir The native helper is `/usr/local/bin/paykit-reader-demo`; `PAYKIT_READER_DEMO_BIN` is a test-only executable override. Its state path and local Pubky endpoints come from the `PAYKIT_READER_*` Compose environment. Reader homeserver registration runs in a separate direct-spawned Node subprocess with bounded output, timeout, and TERM→KILL cancellation because the Pubky JS API does not expose request cancellation; cancellation waits for child settlement before ownership is released. The worker derives the Paykit peer from the public `content-creator` profile, then passes only the closed native helper environment. The state path must end in `.local/paykit-reader/state.v1`. The helper owns encrypted versioned state, owner-only file permissions, fresh-nonce rewrites, and invariant validation. The worker fences status publication and state checkpoints on current kernel-lock ownership, atomically writes its separate owner-only `worker.v1.json` projection, and clears in-memory readiness immediately if ownership is lost. The HTTP server validates the projection again and requires current in-memory ownership before returning a ready browser status. Terminal worker failure closes PID 1 after a coarse error so Compose restart policy applies. -The reader page persists local progress in browser `localStorage` under `pubky-locks-reader-demo.*` and has a visible **Reset reader state** button. Bundle IDs and access credentials are bearer-like local-dev secrets; the demo displays them for debugging only. +The reader page persists local progress in browser `localStorage` under `pubky-locks-reader-demo.*` and has a visible **Reset reader state** button. Retrieved guarded bytes are never persisted: text and JSON render as text, images use a temporary object URL, and other binary content exposes metadata and a temporary download link. Bundle IDs and access credentials are bearer-like local-dev secrets; the demo displays them for debugging only. ## Static drift check diff --git a/examples/js-sdk/app-iframe.js b/examples/js-sdk/app-iframe.js index 65174e8..daf5f04 100644 --- a/examples/js-sdk/app-iframe.js +++ b/examples/js-sdk/app-iframe.js @@ -329,7 +329,7 @@ el.startDemoAuth.addEventListener('click', async () => { await refreshDemoAuthStatus(); return; } - el.demoAuthCommand.textContent = `${result.authorizationUrl}\n\n${result.command}`; + el.demoAuthCommand.textContent = [result.authorizationUrl, result.command].filter(Boolean).join('\n\n'); } catch (error) { showError(el.demoAuthStatus, error); } diff --git a/examples/js-sdk/package.json b/examples/js-sdk/package.json index 4ef4760..1bfe6b4 100644 --- a/examples/js-sdk/package.json +++ b/examples/js-sdk/package.json @@ -20,7 +20,7 @@ "validate:paykit-compose": "node scripts/validate-paykit-compose.mjs", "smoke:paykit-compose": "npm run validate:paykit-compose && npm run test:paykit-reader-worker && node scripts/smoke-paykit-compose.mjs", "smoke": "npm --prefix ../../locks-sdk/bindings/js run smoke:examples", - "check": "node --check scripts/init-config.mjs && node --check scripts/init-paykit-compose.mjs && node --check scripts/generate-paykit-account-tpub.mjs && node --check scripts/reset-paykit-demo.mjs && node --check scripts/validate-paykit-compose.mjs && node --check scripts/electrum-readiness.mjs && node --check scripts/smoke-paykit-compose.mjs && node --check scripts/test-paykit-reader-worker.mjs && node --check scripts/create-user.mjs && node --check scripts/publish-creator-profile.mjs && node --check scripts/authenticate.mjs && node --check scripts/authenticate-paykit.mjs && node --check scripts/register-paykit-reader.mjs && node --check scripts/prepare-paykit-reader.mjs && node --check scripts/receive-paykit-request.mjs && node --check scripts/lib/creator-session-state.mjs && node --check scripts/lib/creator-static-path.mjs && node --check scripts/lib/paykit-reader-helper.mjs && node --check scripts/lib/paykit-reader-status.mjs && node --check scripts/lib/paykit-reader-worker.mjs && node --check scripts/start-demo-server.mjs && node --check scripts/start-reader-demo-server.mjs && node --check app.js && node --check app-iframe.js && node --check creator-identity.js && node --check creator-lock-policy.js && node --check paykit-setup.js && node --check creator-complete-flow.js && node --check reader-app.js && node --check reader-flow.js" + "check": "node --check scripts/init-config.mjs && node --check scripts/init-paykit-compose.mjs && node --check scripts/homegate-bridge.mjs && node --check scripts/generate-paykit-account-tpub.mjs && node --check scripts/reset-paykit-demo.mjs && node --check scripts/validate-paykit-compose.mjs && node --check scripts/electrum-readiness.mjs && node --check scripts/smoke-paykit-compose.mjs && node --check scripts/test-paykit-reader-worker.mjs && node --check scripts/create-user.mjs && node --check scripts/publish-creator-profile.mjs && node --check scripts/authenticate.mjs && node --check scripts/authenticate-paykit.mjs && node --check scripts/register-paykit-reader.mjs && node --check scripts/prepare-paykit-reader.mjs && node --check scripts/receive-paykit-request.mjs && node --check scripts/lib/creator-session-state.mjs && node --check scripts/lib/creator-static-path.mjs && node --check scripts/lib/paykit-reader-helper.mjs && node --check scripts/lib/paykit-reader-status.mjs && node --check scripts/lib/paykit-reader-worker.mjs && node --check scripts/start-demo-server.mjs && node --check scripts/start-reader-demo-server.mjs && node --check app.js && node --check app-iframe.js && node --check creator-identity.js && node --check creator-lock-policy.js && node --check paykit-setup.js && node --check creator-complete-flow.js && node --check reader-app.js && node --check reader-flow.js" }, "dependencies": { "@synonymdev/pubky": "^0.9.3" diff --git a/examples/js-sdk/reader-app.js b/examples/js-sdk/reader-app.js index 5896333..7da7ba3 100644 --- a/examples/js-sdk/reader-app.js +++ b/examples/js-sdk/reader-app.js @@ -85,6 +85,8 @@ async function bootstrap() { el.configStatus.textContent = `Reader demo using Lock Server ${state.config.lockServer.pubky}`; el.configStatus.className = 'ok'; restoreState(); + const resource = new URL(window.location.href).searchParams.get('resource')?.trim(); + if (resource) state.resource = resource; bindEvents(); await refreshPaykitReaderStatus(); render(); @@ -101,6 +103,7 @@ async function bootstrap() { function bindEvents() { el.reset.addEventListener('click', async () => { invalidateWorkflow(); + clearReadResult(); localStorage.removeItem(STATE_KEY); Object.assign(state, { resource: '', @@ -299,7 +302,7 @@ async function submitProof() { state.completion = null; state.accessCredential = null; state.accessCredentialResponse = null; - state.readResult = null; + clearReadResult(); activeSubmissionToken = null; state.submittingProof = false; persistState(); @@ -453,12 +456,7 @@ async function readPaymentContent(handle, path, accessCredential) { }); if (!workflowMatches(handle)) return; state.guardedResourcePath = path; - state.readResult = { - path, - size: result.size, - contentType: result.contentType, - text: result.text, - }; + setReadResult(path, result); persistState(); render(); } @@ -516,12 +514,7 @@ async function readContent(path) { }); if (!workflowMatches(handle)) return; state.guardedResourcePath = path; - state.readResult = { - path, - size: result.size, - contentType: result.contentType, - text: result.text, - }; + setReadResult(path, result); persistState(); render(); await postClientLog('info', 'reader-proxy-read-succeeded', { @@ -554,6 +547,9 @@ function render() { } else if (state.paykitReaderState === 'retrying') { el.paykitReaderStatus.textContent = 'Paykit reader is retrying private protocol processing.'; el.paykitReaderStatus.className = 'warning'; + } else if (state.paykitReaderState === 'waiting_for_creator') { + el.paykitReaderStatus.textContent = 'Paykit reader is waiting for the content creator to authenticate.'; + el.paykitReaderStatus.className = 'muted'; } else if (state.paykitReaderState === 'failed') { el.paykitReaderStatus.textContent = 'Paykit reader worker failed. Inspect coarse container logs.'; el.paykitReaderStatus.className = 'error'; @@ -639,7 +635,7 @@ function render() { el.readStatus.textContent = state.accessCredential ? 'Ready to read guarded content.' : 'Waiting for access credential.'; el.readStatus.className = 'muted'; } - el.readOutput.textContent = state.readResult ? state.readResult.text : ''; + renderReadOutput(); } function renderLockResources() { @@ -704,6 +700,7 @@ function restoreState() { paykitReaderState: 'starting', paykitPaymentRequest: null, baselinePaymentRequestId: null, + readResult: null, }); } catch { localStorage.removeItem(STATE_KEY); @@ -721,6 +718,7 @@ function persistState() { paykitReaderState: _paykitReaderState, paykitPaymentRequest: _paykitPaymentRequest, baselinePaymentRequestId: _baselinePaymentRequestId, + readResult: _readResult, ...persisted } = state; localStorage.setItem(STATE_KEY, JSON.stringify(persisted)); @@ -759,9 +757,60 @@ function clearVerificationState({ clearLoaded = false } = {}) { state.completion = null; state.accessCredential = null; state.accessCredentialResponse = null; + clearReadResult(); +} + +function setReadResult(path, result) { + clearReadResult(); + const objectUrl = result.kind === 'text' + ? null + : URL.createObjectURL(new Blob([result.bytes], { type: result.contentType })); + state.readResult = { + path, + size: result.size, + contentType: result.contentType, + kind: result.kind, + text: result.text, + objectUrl, + }; +} + +function clearReadResult() { + if (state.readResult?.objectUrl) URL.revokeObjectURL(state.readResult.objectUrl); state.readResult = null; } +function renderReadOutput() { + el.readOutput.replaceChildren(); + const result = state.readResult; + if (!result) return; + + if (result.kind === 'text') { + const output = document.createElement('pre'); + output.textContent = result.text; + el.readOutput.append(output); + return; + } + + if (result.kind === 'image') { + const image = document.createElement('img'); + image.src = result.objectUrl; + image.alt = `Guarded content from ${result.path}`; + el.readOutput.append(image); + return; + } + + const metadata = document.createElement('p'); + metadata.textContent = `${result.contentType}, ${result.size} bytes`; + const download = document.createElement('a'); + download.href = result.objectUrl; + download.download = result.path.split('/').pop() || 'guarded-content'; + download.textContent = 'Download guarded content'; + el.readOutput.append(metadata, download); +} + +window.addEventListener('pagehide', clearReadResult); + function workflowMatches(handle) { return workflowHandleMatches(handle, { incarnation: workflowIncarnation, diff --git a/examples/js-sdk/reader-flow.js b/examples/js-sdk/reader-flow.js index 8c7824a..687a067 100644 --- a/examples/js-sdk/reader-flow.js +++ b/examples/js-sdk/reader-flow.js @@ -222,6 +222,9 @@ export function parsePaykitReaderBrowserStatus(value) { if (value.state === 'starting' && hasExactKeys(value, ['version', 'state'])) { return Object.freeze({ ...value }); } + if (value.state === 'waiting_for_creator' && hasExactKeys(value, ['version', 'state'])) { + return Object.freeze({ ...value }); + } if ( value.state === 'waiting' && hasExactKeys(value, ['version', 'state', 'reader_pubky']) @@ -307,10 +310,17 @@ export async function decodeGuardedContentResponse(response) { if (!(response instanceof Response)) throw new Error('guarded resource read returned a non-Response value'); const contentType = response.headers.get('content-type') || 'application/octet-stream'; const array = new Uint8Array(await response.arrayBuffer()); + const mediaType = contentType.split(';', 1)[0].trim().toLowerCase(); + const kind = mediaType.startsWith('text/') || mediaType === 'application/json' || mediaType.endsWith('+json') + ? 'text' + : mediaType.startsWith('image/') + ? 'image' + : 'binary'; return { response, bytes: array, - text: new TextDecoder().decode(array), + kind, + text: kind === 'text' ? new TextDecoder().decode(array) : null, size: array.byteLength, contentType, }; diff --git a/examples/js-sdk/reader.html b/examples/js-sdk/reader.html index bacc428..b3e01d2 100644 --- a/examples/js-sdk/reader.html +++ b/examples/js-sdk/reader.html @@ -11,6 +11,7 @@ input, select, button, textarea { font: inherit; } input[type="text"], textarea { width: 100%; box-sizing: border-box; } pre { background: #f7f7f7; padding: 0.75rem; overflow: auto; white-space: pre-wrap; } + #read-output img { display: block; max-width: 100%; height: auto; } .ok { color: #067d17; } .error { color: #a40000; } .muted { color: #666; } @@ -58,9 +59,9 @@

2. Submit proof bundle

diff --git a/examples/js-sdk/scripts/homegate-bridge.mjs b/examples/js-sdk/scripts/homegate-bridge.mjs new file mode 100644 index 0000000..fee6867 --- /dev/null +++ b/examples/js-sdk/scripts/homegate-bridge.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; + +const listenPort = Number(process.env.HOMEGATE_BRIDGE_PORT ?? 8082); +const configPath = process.env.HOMEGATE_BRIDGE_CONFIG; +const homeserverAdminUrl = process.env.HOMEGATE_BRIDGE_HOMESERVER_ADMIN_URL; +const homeserverAdminPassword = process.env.PUBKY_HOMESERVER_ADMIN_PASSWORD; + +if (!configPath || !homeserverAdminUrl || !homeserverAdminPassword) { + throw new Error('Homegate bridge configuration is incomplete'); +} + +const server = createServer(async (request, response) => { + if (request.method === 'GET' && request.url === '/health') { + return sendJson(response, { ok: true }); + } + if (request.method !== 'POST' || request.url !== '/ip_verification') { + response.writeHead(404).end('not found'); + return; + } + + try { + const [{ testnet }, signupResponse] = await Promise.all([ + readDemoConfig(configPath), + fetch(`${homeserverAdminUrl}/generate_signup_token`, { + headers: { 'x-admin-password': homeserverAdminPassword }, + }), + ]); + if (!signupResponse.ok) throw new Error(`homeserver returned HTTP ${signupResponse.status}`); + const signupCode = (await signupResponse.text()).trim(); + if (!signupCode || typeof testnet?.homeserver !== 'string') { + throw new Error('homeserver signup response is incomplete'); + } + sendJson(response, { signupCode, homeserverPubky: testnet.homeserver }); + } catch { + sendJson(response, { error: 'signup unavailable' }, 503); + } +}); + +server.listen(listenPort, '0.0.0.0', () => { + console.log(`Homegate bridge listening on port ${listenPort}`); +}); + +async function readDemoConfig(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +function sendJson(response, value, status = 200) { + response.writeHead(status, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + }); + response.end(`${JSON.stringify(value)}\n`); +} diff --git a/examples/js-sdk/scripts/init-config.mjs b/examples/js-sdk/scripts/init-config.mjs index 735f292..dc709b7 100644 --- a/examples/js-sdk/scripts/init-config.mjs +++ b/examples/js-sdk/scripts/init-config.mjs @@ -3,7 +3,7 @@ import { demoConfigPath, parseArgs, writeJson } from './lib/paths.mjs'; import { buildDefaultDemoConfig } from './lib/config.mjs'; // Defaults: read lock_server_public_key from ~/.pubky-lock/config.toml and write ./.local/demo-config/config.json. -// Local testnet defaults: http://localhost:15411, http://localhost:15412, localhost:6881. +// Local testnet defaults: http://127.0.0.1:15411, http://127.0.0.1:15412, 127.0.0.1:6881. const args = parseArgs(); const output = typeof args.output === 'string' ? args.output : demoConfigPath; diff --git a/examples/js-sdk/scripts/init-paykit-compose.mjs b/examples/js-sdk/scripts/init-paykit-compose.mjs index 22c0751..615923e 100644 --- a/examples/js-sdk/scripts/init-paykit-compose.mjs +++ b/examples/js-sdk/scripts/init-paykit-compose.mjs @@ -86,6 +86,7 @@ function generatedPaths(root) { paykitConfig: join(root, 'paykit-config', 'config.toml'), bitcoinRpc: join(root, 'bitcoin-rpc', 'bitcoin-rpc.env'), pubkyHomeserver: join(root, 'pubky-homeserver', 'config.toml'), + homegateBridge: join(root, 'homegate-bridge', 'homegate.env'), }; } @@ -116,6 +117,7 @@ export async function initializePaykitCompose({ 'pubky-homeserver', 'paykit-server', 'paykit-config', + 'homegate-bridge', ].map(async (directory) => { const path = join(root, directory); await mkdir(path, { recursive: true, mode: 0o700 }); @@ -149,6 +151,7 @@ export async function initializePaykitCompose({ databasePassword: secrets.locksPostgresPassword, adminPassword: secrets.pubkyHomeserverAdminPassword, })), + writeSecure(paths.homegateBridge, `PUBKY_HOMESERVER_ADMIN_PASSWORD=${secrets.pubkyHomeserverAdminPassword}\n`), ]); if (lockConfigPath) { diff --git a/examples/js-sdk/scripts/lib/config.mjs b/examples/js-sdk/scripts/lib/config.mjs index 3255d0d..e3f1eeb 100644 --- a/examples/js-sdk/scripts/lib/config.mjs +++ b/examples/js-sdk/scripts/lib/config.mjs @@ -9,7 +9,7 @@ export const defaultLockServerConfigPath = '~/.pubky-lock/config.toml'; export const defaultDemoConfig = { demoServer: { - url: 'http://localhost:8080', + url: 'http://127.0.0.1:8080', }, lockServer: { url: 'http://127.0.0.1:3000', @@ -17,13 +17,13 @@ export const defaultDemoConfig = { configPath: defaultLockServerConfigPath, }, paykit: { - url: 'http://localhost:3001', + url: 'http://127.0.0.1:3001', }, testnet: { homeserver: 'pubky8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo', - httpRelay: 'http://localhost:15412', - pkarrRelay: 'http://localhost:15411', - dhtBootstrap: 'localhost:6881', + httpRelay: 'http://127.0.0.1:15412', + pkarrRelay: 'http://127.0.0.1:15411', + dhtBootstrap: '127.0.0.1:6881', }, }; @@ -155,7 +155,7 @@ listen_addr = "0.0.0.0:3001" trusted_public_key = "${lockServerPubky}" [setup] -allowed_origins = ["http://localhost:8080"] +allowed_origins = ["http://127.0.0.1:8080", "http://localhost:8080"] [paykit] receiver_path = "bitkit/server" diff --git a/examples/js-sdk/scripts/lib/creator-session-state.mjs b/examples/js-sdk/scripts/lib/creator-session-state.mjs index 458a49c..d982fc5 100644 --- a/examples/js-sdk/scripts/lib/creator-session-state.mjs +++ b/examples/js-sdk/scripts/lib/creator-session-state.mjs @@ -25,8 +25,8 @@ export async function readCreatorDemoSessionForCurrentRole({ throw error; } - const profile = await readJson(profilePath); - if (!creatorIdentitiesMatch(session, profile)) { + const profile = profilePath ? await readJson(profilePath) : null; + if (!validCreatorSession(session) || (profile && !creatorIdentitiesMatch(session, profile))) { await clearCreatorDemoSession(sessionPath); return null; } @@ -42,8 +42,8 @@ export async function writeCreatorDemoSessionForCurrentRole( profilePath = defaultProfilePath, } = {}, ) { - const profileBeforeWrite = await readJson(profilePath); - if (!creatorIdentitiesMatch(session, profileBeforeWrite)) { + const profileBeforeWrite = profilePath ? await readJson(profilePath) : null; + if (!validCreatorSession(session) || (profileBeforeWrite && !creatorIdentitiesMatch(session, profileBeforeWrite))) { await clearCreatorDemoSession(sessionPath); throw new Error('creator identity changed during demo authentication'); } @@ -51,16 +51,20 @@ export async function writeCreatorDemoSessionForCurrentRole( await writeJson(sessionPath, session, { mode: 0o600 }); await chmod(sessionPath, 0o600); - const profileAfterWrite = await readJson(profilePath); - if (!creatorIdentitiesMatch(session, profileAfterWrite)) { + const profileAfterWrite = profilePath ? await readJson(profilePath) : null; + if (profileAfterWrite && !creatorIdentitiesMatch(session, profileAfterWrite)) { await clearCreatorDemoSession(sessionPath); throw new Error('creator identity changed during demo authentication'); } } function creatorIdentitiesMatch(session, profile) { - return session?.role === 'content-creator' + return validCreatorSession(session) && profile?.role === 'content-creator' - && typeof session.pubky === 'string' && session.pubky === profile.pubky; } + +function validCreatorSession(session) { + return session?.role === 'content-creator' + && /^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(session.pubky ?? ''); +} diff --git a/examples/js-sdk/scripts/lib/paykit-reader-helper.mjs b/examples/js-sdk/scripts/lib/paykit-reader-helper.mjs index 09420fb..c4bf13b 100644 --- a/examples/js-sdk/scripts/lib/paykit-reader-helper.mjs +++ b/examples/js-sdk/scripts/lib/paykit-reader-helper.mjs @@ -191,15 +191,23 @@ export function requireReaderEnvironment(env = process.env) { } } +export async function readReaderCreatorProfile({ + env = process.env, + readProfile = readJson, + loadProfile = loadRoleProfile, +} = {}) { + return env.PAYKIT_READER_CREATOR_PROFILE_PATH + ? readProfile(env.PAYKIT_READER_CREATOR_PROFILE_PATH) + : loadProfile('content-creator'); +} + export async function resolveReaderEnvironment({ env = process.env, loadProfile = loadRoleProfile, } = {}) { const resolved = { ...env }; if (!resolved.PAYKIT_READER_SERVER_PUBKY) { - const profile = resolved.PAYKIT_READER_CREATOR_PROFILE_PATH - ? await readJson(resolved.PAYKIT_READER_CREATOR_PROFILE_PATH).catch(() => undefined) - : await loadProfile('content-creator').catch(() => undefined); + const profile = await readReaderCreatorProfile({ env: resolved, loadProfile }).catch(() => undefined); if (profile?.role !== 'content-creator' || !isCanonicalPubky(profile.pubky)) { throw new Error('valid content-creator profile is required for Paykit reader setup'); } diff --git a/examples/js-sdk/scripts/lib/paykit-reader-status.mjs b/examples/js-sdk/scripts/lib/paykit-reader-status.mjs index 49da7e4..33d99f0 100644 --- a/examples/js-sdk/scripts/lib/paykit-reader-status.mjs +++ b/examples/js-sdk/scripts/lib/paykit-reader-status.mjs @@ -96,6 +96,9 @@ export function validatePaykitReaderWorkerStatus(value) { if (value.state === 'starting' && exactKeys(value, ['version', 'state'])) { return Object.freeze({ ...value }); } + if (value.state === 'waiting_for_creator' && exactKeys(value, ['version', 'state'])) { + return Object.freeze({ ...value }); + } if ( value.state === 'waiting' && exactKeys(value, ['version', 'state', 'reader_pubky']) @@ -165,7 +168,12 @@ export async function readPaykitReaderWorkerStatus(path = paykitReaderWorkerStat } } -export function buildPaykitReaderBrowserStatus(worker, profile, { currentOwner = false } = {}) { +export function buildPaykitReaderBrowserStatus( + worker, + profile, + { currentOwner = false, waitingForCreator = false } = {}, +) { + if (waitingForCreator) return { version: 1, state: 'waiting_for_creator' }; if (!currentOwner) return { version: 1, state: 'starting' }; if (!worker) return { version: 1, state: 'starting' }; if ( diff --git a/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs b/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs index d84ea6a..efa05e4 100644 --- a/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs +++ b/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs @@ -93,6 +93,37 @@ export async function supervisePaykitReaderWorker(task, { onTerminalFailure } = return result; } +export async function waitForCreatorProfile({ + signal, + readProfile, + wait = waitForDelay, +} = {}) { + if (!signal || typeof readProfile !== 'function') { + throw new Error('Paykit reader creator wait dependencies are incomplete'); + } + while (!signal.aborted) { + let profile; + try { + profile = await readProfile(); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + profile = null; + } + if (profile === null) { + await wait(INITIAL_RETRY_MS, signal); + continue; + } + if ( + profile.role !== 'content-creator' + || !/^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(profile.pubky) + ) { + throw new Error('Paykit reader creator profile is invalid'); + } + return profile; + } + return null; +} + export async function runPaykitReaderWorker({ signal, runOperation = runReaderOperation, diff --git a/examples/js-sdk/scripts/publish-creator-profile.mjs b/examples/js-sdk/scripts/publish-creator-profile.mjs index 884151a..d618161 100644 --- a/examples/js-sdk/scripts/publish-creator-profile.mjs +++ b/examples/js-sdk/scripts/publish-creator-profile.mjs @@ -14,12 +14,13 @@ const CANONICAL_PUBKY = /^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/; export async function publishCreatorProfile({ source = roleProfilePath('content-creator'), destination = creatorPublicProfilePath, + profile, } = {}) { - const profile = await readJson(source); - if (profile?.role !== 'content-creator' || !CANONICAL_PUBKY.test(profile.pubky ?? '')) { + const creatorProfile = profile ?? await readJson(source); + if (creatorProfile?.role !== 'content-creator' || !CANONICAL_PUBKY.test(creatorProfile.pubky ?? '')) { throw new Error('valid content-creator profile is required'); } - const publicProfile = Object.freeze({ role: 'content-creator', pubky: profile.pubky }); + const publicProfile = Object.freeze({ role: 'content-creator', pubky: creatorProfile.pubky }); await writeJson(destination, publicProfile); return publicProfile; } diff --git a/examples/js-sdk/scripts/smoke-paykit-compose.mjs b/examples/js-sdk/scripts/smoke-paykit-compose.mjs index c459218..42ad8a0 100644 --- a/examples/js-sdk/scripts/smoke-paykit-compose.mjs +++ b/examples/js-sdk/scripts/smoke-paykit-compose.mjs @@ -10,7 +10,7 @@ import { } from './lib/config.mjs'; import { repoRoot, writeSecret } from './lib/paths.mjs'; import { initializePaykitCompose } from './init-paykit-compose.mjs'; -import { resolveReaderEnvironment } from './lib/paykit-reader-helper.mjs'; +import { readReaderCreatorProfile, resolveReaderEnvironment } from './lib/paykit-reader-helper.mjs'; import { extractBip84AccountXpub } from './generate-paykit-account-tpub.mjs'; import { resolveCreatorStaticPath } from './lib/creator-static-path.mjs'; import { publishCreatorProfile } from './publish-creator-profile.mjs'; @@ -26,7 +26,9 @@ assert.match(composeSource, /^# Local development and demonstration only;/); assert.match(composeSource, /^name: pubky-locks-paykit-demo$/m); assert.match(creatorAppSource, /hasExactKeys\(event\.data, \['type', 'state', 'code'\]\)/); assert.match(creatorAppSource, /body: JSON\.stringify\(\{ level \}\)/); +assert.match(creatorAppSource, /\[result\.authorizationUrl, result\.command\]\.filter\(Boolean\)/); assert.doesNotMatch(creatorServerSource, /JSON\.stringify\(entry\)|url\.search/); +assert.match(creatorServerSource, /if \(!externalWallet\) \{\s+result\.command =/); assert.doesNotMatch(lockAuthoritySource, /dev: legacy-connect authorization URL/); assert.match(defaultComposeSource, /^services:/); assert.doesNotMatch(defaultComposeSource, /^ paykit-server:/m); @@ -89,7 +91,7 @@ await assert.rejects( /content-creator profile/, ); const config = buildPaykitServerConfig({ lockServerPubky }); -assert.equal(config, `[http]\nlisten_addr = "0.0.0.0:3001"\n\n[locks]\ntrusted_public_key = "${lockServerPubky}"\n\n[setup]\nallowed_origins = ["http://localhost:8080"]\n\n[paykit]\nreceiver_path = "bitkit/server"\nreceiver_path_priority = ["bitkit"]\nnetwork = "testnet"\n\n[bitcoin]\nnetwork = "regtest"\n\n[electrum]\nendpoint = "tcp://fulcrum:50001"\npoll_interval = "1s"\nrequest_timeout = "10s"\nconnect_retries = 1\n\n[outbox]\npoll_interval = "500ms"\nbatch_size = 16\nlease_duration = "30s"\nretry_initial = "1s"\nretry_max = "5m"\n`); +assert.equal(config, `[http]\nlisten_addr = "0.0.0.0:3001"\n\n[locks]\ntrusted_public_key = "${lockServerPubky}"\n\n[setup]\nallowed_origins = ["http://127.0.0.1:8080", "http://localhost:8080"]\n\n[paykit]\nreceiver_path = "bitkit/server"\nreceiver_path_priority = ["bitkit"]\nnetwork = "testnet"\n\n[bitcoin]\nnetwork = "regtest"\n\n[electrum]\nendpoint = "tcp://fulcrum:50001"\npoll_interval = "1s"\nrequest_timeout = "10s"\nconnect_retries = 1\n\n[outbox]\npoll_interval = "500ms"\nbatch_size = 16\nlease_duration = "30s"\nretry_initial = "1s"\nretry_max = "5m"\n`); assert.throws(() => buildPaykitServerConfig({ lockServerPubky: 'invalid' }), /Lock Server Pubky/); assert.deepEqual(validatePaykitComposeEnvironment({ PAYKIT_DATABASE_URL: 'postgres://paykit:secret@paykit-postgres:5432/paykit', @@ -151,6 +153,7 @@ try { 'paykit-server/paykit.env', 'bitcoin-rpc/bitcoin-rpc.env', 'pubky-homeserver/config.toml', + 'homegate-bridge/homegate.env', ]; for (const file of generatedFiles) { assert.equal((await stat(join(generatedRoot, file))).mode & 0o777, 0o600, `${file} must be mode 0600`); @@ -169,6 +172,7 @@ try { 'pubky-homeserver', 'paykit-server', 'paykit-config', + 'homegate-bridge', ]) { assert.equal((await stat(join(generatedRoot, directory))).mode & 0o777, 0o700, `${directory} must be mode 0700`); } @@ -233,6 +237,13 @@ try { role: 'content-creator', pubky: creatorPubky, }); + assert.deepEqual(await readReaderCreatorProfile({ + env: { PAYKIT_READER_CREATOR_PROFILE_PATH: publicProfilePath }, + loadProfile: async () => { throw new Error('private creator profile must not be loaded'); }, + }), { + role: 'content-creator', + pubky: creatorPubky, + }); const readerFromPublicProfile = await resolveReaderEnvironment({ env: { ...readerBaseEnvironment, @@ -241,6 +252,14 @@ try { loadProfile: async () => { throw new Error('private creator profile must not be loaded'); }, }); assert.equal(readerFromPublicProfile.PAYKIT_READER_SERVER_PUBKY, creatorPubky); + await publishCreatorProfile({ + profile: { role: 'content-creator', pubky: lockServerPubky }, + destination: publicProfilePath, + }); + assert.deepEqual(JSON.parse(await readFile(publicProfilePath, 'utf8')), { + role: 'content-creator', + pubky: lockServerPubky, + }); } finally { await rm(configOnlyRoot, { recursive: true, force: true }); } @@ -266,6 +285,7 @@ for (const service of [ 'bitcoin-bootstrap:', 'fulcrum:', 'electrum-readiness:', + 'homegate-bridge:', 'paykit-config:', 'demo-config:', 'paykit-server:', @@ -279,12 +299,14 @@ for (const required of [ 'node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4', 'additional_contexts:', 'PUBKY_CORE_REV: 75eb1324f86e8caa16c41f18a2cd6b8e1909ee7b', - 'https://github.com/pubky/paykit-server.git#f38c7915e6b9b104e040773e78438f8aa984c46c', - 'https://github.com/pubky/paykit-rs.git#52a852995bfc457b78d32f5a45f6741766a89bba:paykit-lib', - 'https://github.com/pubky/paykit-rs.git#52a852995bfc457b78d32f5a45f6741766a89bba:paykit-sdk', + 'https://github.com/pubky/paykit-server.git#5ed3e8e849a16045c26c37a75068625dda333785', + 'https://github.com/pubky/paykit-rs.git#6b241878a9bba5cecea919c0298c3f90624be6ff:paykit-lib', + 'https://github.com/pubky/paykit-rs.git#6b241878a9bba5cecea919c0298c3f90624be6ff:paykit-sdk', 'https://github.com/pubky/locks.git#df5ea1b6d8dcdec3a9b5a915c3f57bca69d75c8a', '127.0.0.1:${LOCKS_PAYKIT_PORT:-3001}:3001', '127.0.0.1:${LOCKS_READER_DEMO_PORT:-8088}:8081', + '127.0.0.1:${LOCKS_ELECTRUM_PORT:-60001}:50001', + '127.0.0.1:${LOCKS_HOMEGATE_PORT:-6288}:8082', 'bitcoin-cli -conf=\\"$${BITCOIN_DATA}/bitcoin.conf\\" -regtest getblockchaininfo', 'user: "1000:1000"', '.local/bitcoin-bootstrap:/home/bitcoin/.bitcoin', @@ -297,6 +319,7 @@ for (const required of [ './.local/pubky-homeserver:/run/compose-local/pubky-homeserver:ro', './.local/locks-server:/run/compose-local/locks-server:ro', './.local/paykit-server:/run/compose-local/paykit-server:ro', + './.local/homegate-bridge:/run/compose-local/homegate-bridge:ro', 'node examples/js-sdk/scripts/init-paykit-compose.mjs', 'exec /entrypoint.sh Fulcrum', 'PAYKIT_READER_DEMO_BIN:', @@ -327,6 +350,12 @@ for (const privateVolume of ['name: locks_lock-home', 'name: pubky-locks-demo-pu } assert.ok(!compose.includes('- ./:/workspace'), 'services must not mount the repository root'); assert.ok(!compose.includes('- lock-home:/root'), 'demo services must not mount Lock Server identity state'); +const creatorService = compose.slice(compose.indexOf(' creator-demo:'), compose.indexOf('\n reader-demo:')); +assert.ok(creatorService.includes('--external-wallet'), 'creator must use the authenticated external wallet identity'); +assert.ok(creatorService.includes('rm -f /workspace/.local/creator-public/profile.json'), 'creator must clear stale public identity before external wallet auth'); +assert.ok(!creatorService.includes('create-user -- --role content-creator'), 'external wallet mode must not create a second creator identity'); +assert.ok(!creatorService.includes('.local/content-creator'), 'external wallet mode must not mount creator recovery state'); +assert.ok(!creatorService.includes('--allow-unhealthy'), 'creator preflight must fail closed'); const readerService = compose.slice(compose.indexOf(' reader-demo:'), compose.indexOf('\nvolumes:')); assert.ok(!readerService.includes('.local/js-sdk-demo'), 'reader must not mount creator session state'); assert.ok(!readerService.includes('.local/content-creator'), 'reader must not mount creator recovery state'); @@ -334,10 +363,15 @@ assert.ok(readerService.includes('.local/creator-public'), 'reader requires only assert.ok(readerService.includes('PAYKIT_READER_WORKER_ENABLED: "1"'), 'reader must enable its embedded Paykit worker'); assert.ok(readerService.includes('npm --prefix examples/js-sdk run create-user -- --role content-viewer'), 'reader must create or reuse its recovery identity'); assert.ok(readerService.includes('exec node examples/js-sdk/scripts/start-reader-demo-server.mjs'), 'reader server must replace its bootstrap shell as PID 1'); +assert.ok(!readerService.includes('--allow-unhealthy'), 'reader preflight must fail closed'); assert.ok(readerService.includes('healthcheck:'), 'reader must expose worker-aware Compose health'); assert.ok(readerService.includes('http://127.0.0.1:8081/api/paykit-reader/status'), 'reader health must use the closed worker status endpoint'); assert.ok(readerService.includes('restart: unless-stopped'), 'reader worker must have an explicit restart policy'); assert.ok(!compose.includes('POSTGRES_PASSWORD: locks'), 'database credentials must not be committed inline'); +assert.ok(!compose.includes('./locks-sdk/bindings/js/pkg:/workspace/locks-sdk/bindings/js/pkg'), 'demo images must provide their own WASM package'); +for (const required of ['FROM rust:1.91.1-slim-bookworm@sha256:8514999d4786ef12efe89239e86b3d0a021b94b9d35108c8efe6c79ca7dc1a65 AS locks-sdk-wasm', 'cargo install wasm-pack --version 0.13.1 --locked', 'wasm-pack build --target web --out-dir pkg', 'COPY --from=locks-sdk-wasm']) { + assert.ok(jsDemoDockerfile.includes(required), `JS demo image missing ${required}`); +} assert.ok(locksEntrypoint.includes('LOCKS_PUBLIC_CONFIG'), 'Lock Server must publish an explicit public artifact'); for (const required of ['[paykit]', 'server_url = "http://127.0.0.1:3001"', 'minimum_confirmations = 0']) { assert.ok(locksEntrypoint.includes(required), `Locks generated config missing ${required}`); diff --git a/examples/js-sdk/scripts/start-demo-server.mjs b/examples/js-sdk/scripts/start-demo-server.mjs index 9672471..0ac43ea 100644 --- a/examples/js-sdk/scripts/start-demo-server.mjs +++ b/examples/js-sdk/scripts/start-demo-server.mjs @@ -11,9 +11,11 @@ import { writeCreatorDemoSessionForCurrentRole, } from './lib/creator-session-state.mjs'; import { resolveCreatorStaticPath } from './lib/creator-static-path.mjs'; +import { publishCreatorProfile } from './publish-creator-profile.mjs'; const args = parseArgs(); const allowUnhealthy = Boolean(args['allow-unhealthy']); +const externalWallet = Boolean(args['external-wallet']); const config = await readDemoConfig(); const serviceConfig = withInternalServiceUrls(config); const port = Number(new URL(config.demoServer.url).port || 8080); @@ -39,6 +41,8 @@ if (allowUnhealthy && preflightStatus.checks.some((check) => !check.ok)) { console.warn('Starting despite unhealthy preflight because --allow-unhealthy was provided.'); } +if (externalWallet) await readCurrentCreatorSession(); + const server = createServer(async (request, response) => { try { const url = new URL(request.url, config.demoServer.url); @@ -105,17 +109,19 @@ async function startDemoAuth() { demoAuthPromise = activeDemoAuthFlow .awaitApproval() .then(async (session) => { - await writeCreatorDemoSessionForCurrentRole({ + const creatorSession = { role: 'content-creator', pubky: session.info.publicKey.toString(), capabilities: session.info.capabilities, exported_session: session.export(), authenticated_at: new Date().toISOString(), - }); + }; + await writeCreatorDemoSessionForCurrentRole(creatorSession, sessionStateOptions()); + if (externalWallet) await publishCreatorProfile({ profile: creatorSession }); return session; }) .catch((error) => { - console.error('demo auth failed'); + console.error(`demo auth failed: ${error instanceof Error ? error.message : String(error)}`); }) .finally(() => { activeDemoAuthFlow = null; @@ -124,17 +130,20 @@ async function startDemoAuth() { demoAuthPromise = null; }); } - return { + const result = { authenticated: false, role: 'content-creator', authorizationUrl: activeDemoAuthUrl, startedAt: activeDemoAuthStartedAt, - command: `npm --prefix examples/js-sdk run authenticate -- --role content-creator --auth "${activeDemoAuthUrl}"`, }; + if (!externalWallet) { + result.command = `npm --prefix examples/js-sdk run authenticate -- --role content-creator --auth "${activeDemoAuthUrl}"`; + } + return result; } async function demoAuthStatus() { - const session = await readCreatorDemoSessionForCurrentRole(); + const session = await readCurrentCreatorSession(); if (session) { if (debugEnabled) { console.log(`[demo] demo-auth persisted session pubky=${session.pubky} path=./.local/js-sdk-demo/content-creator-session.json`); @@ -157,7 +166,17 @@ async function demoAuthStatus() { } async function hasPersistedDemoSession() { - return Boolean(await readCreatorDemoSessionForCurrentRole()); + return Boolean(await readCurrentCreatorSession()); +} + +function sessionStateOptions() { + return externalWallet ? { profilePath: null } : {}; +} + +async function readCurrentCreatorSession() { + const session = await readCreatorDemoSessionForCurrentRole(sessionStateOptions()); + if (session && externalWallet) await publishCreatorProfile({ profile: session }); + return session; } function publicBrowserConfig(source) { @@ -228,6 +247,9 @@ async function runPreflight(source) { push('config', false, error.message); } + const wasmPackage = join(repoRoot, 'locks-sdk/bindings/js/pkg/locks_sdk_wasm_bg.wasm'); + push('WASM package', existsSync(wasmPackage), existsSync(wasmPackage) ? 'present' : 'missing'); + await checkHttp(`${source.lockServer.url}/healthz`, 'lock-server /healthz', checks, (status) => status >= 200 && status < 300); await checkHttp(`${source.lockServer.url}/readyz`, 'lock-server /readyz', checks, (status) => status >= 200 && status < 300); await checkHttp(source.testnet.pkarrRelay, 'pkarr relay', checks, (status) => status < 500); // status < 500 diff --git a/examples/js-sdk/scripts/start-reader-demo-server.mjs b/examples/js-sdk/scripts/start-reader-demo-server.mjs index e5febc8..12ad8ef 100644 --- a/examples/js-sdk/scripts/start-reader-demo-server.mjs +++ b/examples/js-sdk/scripts/start-reader-demo-server.mjs @@ -6,9 +6,11 @@ import { extname, join, normalize } from 'node:path'; import { readDemoConfig, validateDemoConfig, pubkyAuthRelayInboxUrl, withInternalServiceUrls } from './lib/config.mjs'; import { examplesRoot, parseArgs, readJson, repoRoot, roleProfilePath } from './lib/paths.mjs'; import { resolveExistingPathWithin } from './lib/creator-static-path.mjs'; +import { readReaderCreatorProfile } from './lib/paykit-reader-helper.mjs'; import { runPaykitReaderWorker, supervisePaykitReaderWorker, + waitForCreatorProfile, } from './lib/paykit-reader-worker.mjs'; import { buildPaykitReaderBrowserStatus, @@ -24,9 +26,14 @@ const readerUrl = new URL(config.demoServer.url); readerUrl.port = String(args.port ?? 8081); const readerServerUrl = readerUrl.toString().replace(/\/$/, ''); const preflightStatus = await runPreflight(serviceConfig); -const workerEnabled = process.env.PAYKIT_READER_WORKER_ENABLED === '1'; +const externalReaderPubky = process.env.PAYKIT_EXTERNAL_READER_PUBKY?.trim() ?? ''; +if (externalReaderPubky && !/^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(externalReaderPubky)) { + throw new Error('PAYKIT_EXTERNAL_READER_PUBKY must be a canonical Pubky'); +} +const workerEnabled = !externalReaderPubky && process.env.PAYKIT_READER_WORKER_ENABLED === '1'; const workerController = new AbortController(); let workerOwnsState = false; +let workerWaitingForCreator = workerEnabled; logStartupDiagnostics(config, preflightStatus); @@ -62,6 +69,7 @@ const server = createServer(async (request, response) => { if (request.method === 'GET' && url.pathname === '/api/paykit-reader/status') { const status = await publicPaykitReaderStatus({ currentOwner: workerEnabled && workerOwnsState, + waitingForCreator: workerWaitingForCreator, }); return sendJson(response, status, ['starting', 'failed'].includes(status.state) ? 503 : 200); } @@ -96,11 +104,7 @@ server.listen(Number(readerUrl.port), () => { const workerTask = workerEnabled ? supervisePaykitReaderWorker( - runPaykitReaderWorker({ - signal: workerController.signal, - writeWorkerStatus: writePaykitReaderWorkerStatus, - onOwnershipChange: (owned) => { workerOwnsState = owned; }, - }), + runWorkerAfterCreatorProfile(), { onTerminalFailure: handleTerminalWorkerFailure }, ) : Promise.resolve({ status: 'stopped' }); @@ -118,16 +122,34 @@ function publicBrowserConfig(source) { }; } +async function runWorkerAfterCreatorProfile() { + await waitForCreatorProfile({ + signal: workerController.signal, + readProfile: readReaderCreatorProfile, + }); + if (workerController.signal.aborted) return { status: 'stopped' }; + workerWaitingForCreator = false; + return runPaykitReaderWorker({ + signal: workerController.signal, + writeWorkerStatus: writePaykitReaderWorkerStatus, + onOwnershipChange: (owned) => { workerOwnsState = owned; }, + }); +} + export async function publicPaykitReaderStatus({ readWorker = readPaykitReaderWorkerStatus, readProfile = () => readJson(roleProfilePath('content-viewer')), currentOwner = false, + waitingForCreator = false, } = {}) { + if (externalReaderPubky) { + return { version: 1, state: 'waiting', reader_pubky: externalReaderPubky }; + } const [worker, profile] = await Promise.all([ Promise.resolve().then(readWorker).catch(() => null), Promise.resolve().then(readProfile).catch(() => null), ]); - return buildPaykitReaderBrowserStatus(worker, profile, { currentOwner }); + return buildPaykitReaderBrowserStatus(worker, profile, { currentOwner, waitingForCreator }); } async function handleTerminalWorkerFailure(error) { @@ -176,6 +198,8 @@ async function runPreflight(source) { } catch (error) { checks.push({ name: 'config', ok: false, message: error.message }); } + const wasmPackage = join(repoRoot, 'locks-sdk/bindings/js/pkg/locks_sdk_wasm_bg.wasm'); + checks.push({ name: 'WASM package', ok: existsSync(wasmPackage), message: existsSync(wasmPackage) ? 'present' : 'missing' }); await checkHttp(`${source.lockServer.url}/healthz`, 'lock-server /healthz', checks, (status) => status === 200); await checkHttp(`${source.lockServer.url}/readyz`, 'lock-server /readyz', checks, (status) => status === 200); await checkHttp(source.testnet.pkarrRelay, 'pkarr relay', checks, (status) => status === 200 || status === 404); diff --git a/examples/js-sdk/scripts/test-paykit-reader-worker.mjs b/examples/js-sdk/scripts/test-paykit-reader-worker.mjs index b76e93d..bf2be2d 100644 --- a/examples/js-sdk/scripts/test-paykit-reader-worker.mjs +++ b/examples/js-sdk/scripts/test-paykit-reader-worker.mjs @@ -13,6 +13,7 @@ import { acquirePaykitReaderOwnership, runPaykitReaderWorker, supervisePaykitReaderWorker, + waitForCreatorProfile, } from './lib/paykit-reader-worker.mjs'; import { buildPaykitReaderBrowserStatus, @@ -115,6 +116,35 @@ assert.deepEqual(parsePaykitReaderBrowserStatus({ reader_pubky: readerPubky, error: 'receive_timeout', }); +assert.deepEqual( + parsePaykitReaderBrowserStatus({ version: 1, state: 'waiting_for_creator' }), + { version: 1, state: 'waiting_for_creator' }, +); + +let creatorProfileReads = 0; +const creatorProfileWaits = []; +assert.deepEqual(await waitForCreatorProfile({ + signal: new AbortController().signal, + readProfile: async () => { + creatorProfileReads += 1; + if (creatorProfileReads < 3) { + const error = new Error('profile not published yet'); + error.code = 'ENOENT'; + throw error; + } + return { role: 'content-creator', pubky: readerPubky }; + }, + wait: async (milliseconds) => { creatorProfileWaits.push(milliseconds); }, +}), { role: 'content-creator', pubky: readerPubky }); +assert.equal(creatorProfileReads, 3); +assert.deepEqual(creatorProfileWaits, [1_000, 1_000]); +await assert.rejects( + waitForCreatorProfile({ + signal: new AbortController().signal, + readProfile: async () => { throw new SyntaxError('invalid profile JSON'); }, + }), + /invalid profile JSON/, +); const newerReceived = { ...received, @@ -256,6 +286,13 @@ try { ), { version: 1, state: 'starting' }, ); + assert.deepEqual( + buildPaykitReaderBrowserStatus(null, null, { + currentOwner: false, + waitingForCreator: true, + }), + { version: 1, state: 'waiting_for_creator' }, + ); } finally { await rm(statusRoot, { recursive: true, force: true }); } @@ -407,4 +444,4 @@ assert.deepEqual(await timedRegistration, { status: 'timeout' }); assert.equal(registrationTimedOutSignal, true); assert.equal(timedRegistrationCleanupComplete, true); -console.log('Paykit reader worker check passed'); \ No newline at end of file +console.log('Paykit reader worker check passed'); diff --git a/examples/js-sdk/scripts/validate-paykit-compose.mjs b/examples/js-sdk/scripts/validate-paykit-compose.mjs index d4b8fd8..bd4218b 100644 --- a/examples/js-sdk/scripts/validate-paykit-compose.mjs +++ b/examples/js-sdk/scripts/validate-paykit-compose.mjs @@ -14,6 +14,7 @@ const REQUIRED_SERVICES = [ 'fulcrum', 'electrum-readiness', 'pubky-testnet', + 'homegate-bridge', 'locks-server', 'paykit-config', 'demo-config', @@ -39,6 +40,7 @@ export function validateSafeComposeModel(model) { 'electrum-readiness', 'paykit-config', 'demo-config', + 'homegate-bridge', 'creator-demo', 'reader-demo', ]) { @@ -84,8 +86,10 @@ export function validateSafeComposeModel(model) { ) { throw new Error('bitcoin-bootstrap must use reset-managed scratch state'); } - for (const port of model.services['pubky-testnet'].ports ?? []) { - if (port.host_ip !== '127.0.0.1') throw new Error('published demo ports must bind to loopback'); + for (const service of Object.values(model.services)) { + for (const port of service.ports ?? []) { + if (port.host_ip !== '127.0.0.1') throw new Error('published demo ports must bind to loopback'); + } } return model; } diff --git a/locks-sdk/bindings/js/scripts/smoke-examples.mjs b/locks-sdk/bindings/js/scripts/smoke-examples.mjs index 2317e87..3bb155f 100644 --- a/locks-sdk/bindings/js/scripts/smoke-examples.mjs +++ b/locks-sdk/bindings/js/scripts/smoke-examples.mjs @@ -170,8 +170,8 @@ const required = { index: ['iframe modal', 'id="demo-auth"', 'id="creator-publishing"', '/examples/js-sdk/app.js', 'Select primary file', 'id="primary-content-file"', 'Select secondary files', 'id="secondary-content-files"', 'multiple', 'id="selected-resources"', 'id="selected-resource-list"', 'id="lock-type"', '', '', 'id="dev-static-fields"', 'id="paykit-payment-fields" hidden', 'id="paykit-amount-sats"', 'id="paykit-setup-status"', 'id="retry-paykit-setup"'], iframe: ['iframe modal', 'id="demo-auth"', 'id="creator-publishing"', '/examples/js-sdk/app-iframe.js', 'id="lock-type"', '', '', 'id="dev-static-fields"', 'id="paykit-payment-fields" hidden', 'id="paykit-amount-sats"', 'id="paykit-setup-status"', 'id="retry-paykit-setup"'], flows: ['Both creator pages use iframe auth', '/examples/js-sdk/', '/examples/js-sdk/iframe.html'], - readerHtml: ['id="content-lock-resource"', 'id="lock-resources"', 'id="primary-resource-list"', 'id="secondary-resource-list"', 'id="reset-reader-state"', 'id="read-content"', 'id="reader-public-key" readonly', 'id="refresh-paykit-reader"', 'id="paykit-reader-status"', 'id="paykit-reader-payment"', 'id="paykit-reader-commands"', 'id="poll-payment"', 'paykit-payment', 'Paykit reader worker runs automatically', '/reader-app.js'], - initConfig: ['~/.pubky-lock/config.toml', './.local/demo-config/config.json', 'lock_server_public_key', 'http://localhost:15411', 'http://localhost:15412', 'localhost:6881'], + readerHtml: ['id="content-lock-resource"', 'id="lock-resources"', 'id="primary-resource-list"', 'id="secondary-resource-list"', 'id="reset-reader-state"', 'id="read-content"', 'id="reader-public-key" readonly', 'id="refresh-paykit-reader"', 'id="paykit-reader-status"', 'id="paykit-reader-payment"', 'id="paykit-reader-commands"', 'id="poll-payment"', 'paykit-payment', 'Paykit reader identity is prepared automatically', '/reader-app.js'], + initConfig: ['~/.pubky-lock/config.toml', './.local/demo-config/config.json', 'lock_server_public_key', 'http://127.0.0.1:15411', 'http://127.0.0.1:15412', '127.0.0.1:6881'], createUser: ['requiredRole', 'Keypair.random()', 'createRecoveryFile', 'profile.json', '--force', 'content-creator', 'content-viewer', 'lock-server', 'clearPreparedReaderStatus', 'clearCreatorDemoSession'], authenticate: ['requiredRole', 'readAuthFromPrompt', 'signer.signup', 'approveAuthRequest', '--auth', 'already'], authenticatePaykit: [ @@ -214,7 +214,7 @@ const required = { startServer: ['createServer', '--allow-unhealthy', 'pubkyAuthRelayInboxUrl', '/api/demo-auth/start', '/api/demo-auth/status', '/config.json', 'awaitApproval', 'content-creator-session.json', 'readCreatorDemoSessionForCurrentRole', 'writeCreatorDemoSessionForCurrentRole', '/healthz', '/readyz', 'paykit: source.paykit'], startReaderServer: ['createServer', '--allow-unhealthy', 'runPaykitReaderWorker', 'supervisePaykitReaderWorker', 'workerOwnsState', 'handleTerminalWorkerFailure', 'writePaykitReaderWorkerStatus', 'readPaykitReaderWorkerStatus', 'AbortController', 'SIGTERM', '/reader/', '/config.json', '/api/health', '/api/preflight', '/api/debug/config', '/api/paykit-reader/status', '/api/client-log', "'cache-control': 'no-store'", '8081', 'never proxy'], pathsLib: ['localPath', 'roleDir', 'demoConfigPath', 'contentCreatorSessionPath', 'paykitReaderPreparedPath', 'paykitReaderOwnershipPath', 'prepared.v1.json', 'owner.lock'], - configLib: ['readDemoConfig', 'writeDemoConfig', 'parseLockServerTomlPublicKey', 'pubkyAuthRelayInboxUrl', 'validateDemoConfig', "url: 'http://localhost:3001'", "['paykit', 'url']"], + configLib: ['readDemoConfig', 'writeDemoConfig', 'parseLockServerTomlPublicKey', 'pubkyAuthRelayInboxUrl', 'validateDemoConfig', "url: 'http://127.0.0.1:3001'", "['paykit', 'url']"], pubkyLib: ["from '@synonymdev/pubky'", 'Pubky.testnet', 'Keypair.fromRecoveryFile', 'keypair.secret()', 'loadRoleSecret', 'AuthFlowKind', 'PublicKey.from'], creator: [ "from '../../locks-sdk/bindings/js/pkg/locks_sdk_wasm.js'", @@ -385,14 +385,16 @@ const { const creatorSessionTestDir = mkdtempSync(join(tmpdir(), 'locks-creator-session-')); const creatorSessionTestPath = join(creatorSessionTestDir, 'content-creator-session.json'); const creatorProfileTestPath = join(creatorSessionTestDir, 'profile.json'); +const firstCreatorPubky = 'pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy'; +const secondCreatorPubky = 'pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo'; try { writeFileSync(creatorSessionTestPath, '{"exported_session":"sensitive"}'); await clearCreatorDemoSession(creatorSessionTestPath); assert.equal(existsSync(creatorSessionTestPath), false); await clearCreatorDemoSession(creatorSessionTestPath); - writeFileSync(creatorProfileTestPath, JSON.stringify({ role: 'content-creator', pubky: 'creator-b' })); - writeFileSync(creatorSessionTestPath, JSON.stringify({ role: 'content-creator', pubky: 'creator-a', exported_session: 'old-secret' })); + writeFileSync(creatorProfileTestPath, JSON.stringify({ role: 'content-creator', pubky: secondCreatorPubky })); + writeFileSync(creatorSessionTestPath, JSON.stringify({ role: 'content-creator', pubky: firstCreatorPubky, exported_session: 'old-secret' })); assert.equal(await readCreatorDemoSessionForCurrentRole({ sessionPath: creatorSessionTestPath, profilePath: creatorProfileTestPath, @@ -401,14 +403,14 @@ try { await assert.rejects( writeCreatorDemoSessionForCurrentRole( - { role: 'content-creator', pubky: 'creator-a', exported_session: 'late-old-secret' }, + { role: 'content-creator', pubky: firstCreatorPubky, exported_session: 'late-old-secret' }, { sessionPath: creatorSessionTestPath, profilePath: creatorProfileTestPath }, ), /creator identity changed during demo authentication/, ); assert.equal(existsSync(creatorSessionTestPath), false); - const currentSession = { role: 'content-creator', pubky: 'creator-b', exported_session: 'current-secret' }; + const currentSession = { role: 'content-creator', pubky: secondCreatorPubky, exported_session: 'current-secret' }; await writeCreatorDemoSessionForCurrentRole(currentSession, { sessionPath: creatorSessionTestPath, profilePath: creatorProfileTestPath, @@ -421,6 +423,18 @@ try { currentSession, ); assert.equal(statSync(creatorSessionTestPath).mode & 0o777, 0o600); + const externalSession = { role: 'content-creator', pubky: firstCreatorPubky, exported_session: 'external-secret' }; + await writeCreatorDemoSessionForCurrentRole(externalSession, { + sessionPath: creatorSessionTestPath, + profilePath: null, + }); + assert.deepEqual( + await readCreatorDemoSessionForCurrentRole({ + sessionPath: creatorSessionTestPath, + profilePath: null, + }), + externalSession, + ); } finally { rmSync(creatorSessionTestDir, { recursive: true, force: true }); } @@ -965,8 +979,15 @@ const guardedResponse = new Response(new TextEncoder().encode('payment unlocked' }); const decodedGuarded = await decodeGuardedContentResponse(guardedResponse); assert.equal(decodedGuarded.contentType, 'text/plain; charset=utf-8'); +assert.equal(decodedGuarded.kind, 'text'); assert.equal(decodedGuarded.text, 'payment unlocked'); assert.equal(decodedGuarded.size, 16); +const decodedImage = await decodeGuardedContentResponse(new Response(Uint8Array.of(1, 2, 3), { + headers: { 'content-type': 'image/png' }, +})); +assert.equal(decodedImage.kind, 'image'); +assert.equal(decodedImage.text, null); +assert.deepEqual([...decodedImage.bytes], [1, 2, 3]); const { buildReaderHelperInput, diff --git a/locks-sdk/src/transport.rs b/locks-sdk/src/transport.rs index 67f6b42..785cfc6 100644 --- a/locks-sdk/src/transport.rs +++ b/locks-sdk/src/transport.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::net::IpAddr; use crate::error::{LocksSdkError, Result}; use url::Url; @@ -51,7 +52,11 @@ fn apply_endpoint_to_url( .domain .as_deref() .ok_or(LocksSdkError::MissingBrowserEndpointDomain)?; - let is_testnet_domain = domain == "localhost" || testnet_host == Some(domain); + let is_testnet_domain = domain == "localhost" + || domain + .parse::() + .is_ok_and(|address| address.is_loopback()) + || testnet_host == Some(domain); if is_testnet_domain { url.set_scheme("http") @@ -171,6 +176,26 @@ mod tests { assert_eq!(request.url.as_str(), "http://localhost:55433/connect"); } + #[test] + fn browser_request_rewrite_uses_http_port_for_loopback_ip_endpoint() { + let mut params = BTreeMap::new(); + params.insert(HTTP_PORT_PARAM, 55433); + let endpoint = BrowserEndpoint { + domain: Some("127.0.0.1".to_owned()), + port: Some(443), + params, + }; + + let request = rewrite_browser_request( + "https://_pubky.pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo/connect", + &endpoint, + None, + ) + .unwrap(); + + assert_eq!(request.url.as_str(), "http://127.0.0.1:55433/connect"); + } + #[test] fn browser_request_rewrite_requires_http_port_for_localhost_endpoint() { let endpoint = BrowserEndpoint { diff --git a/locks-server/src/app_state/pubky_clients.rs b/locks-server/src/app_state/pubky_clients.rs index 586e431..b7b2766 100644 --- a/locks-server/src/app_state/pubky_clients.rs +++ b/locks-server/src/app_state/pubky_clients.rs @@ -1,45 +1,35 @@ use crate::config::PubkyNetwork; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum PubkyClientConstructor { - Mainnet, - Testnet, -} +const LOCAL_TESTNET_HOST: &str = "127.0.0.1"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum PubkyHttpClientConstructor { Mainnet, - Testnet, -} - -pub(super) fn pubky_client_constructor(network: PubkyNetwork) -> PubkyClientConstructor { - match network { - PubkyNetwork::Mainnet => PubkyClientConstructor::Mainnet, - PubkyNetwork::Testnet => PubkyClientConstructor::Testnet, - } + Testnet(&'static str), } pub(super) fn pubky_http_client_constructor(network: PubkyNetwork) -> PubkyHttpClientConstructor { match network { PubkyNetwork::Mainnet => PubkyHttpClientConstructor::Mainnet, - PubkyNetwork::Testnet => PubkyHttpClientConstructor::Testnet, + PubkyNetwork::Testnet => PubkyHttpClientConstructor::Testnet(LOCAL_TESTNET_HOST), } } pub(super) fn build_pubky_http_client(network: PubkyNetwork) -> pubky::PubkyHttpClient { match pubky_http_client_constructor(network) { PubkyHttpClientConstructor::Mainnet => pubky::PubkyHttpClient::new(), - PubkyHttpClientConstructor::Testnet => pubky::PubkyHttpClient::testnet(), + PubkyHttpClientConstructor::Testnet(host) => { + let mut builder = pubky::PubkyHttpClient::builder(); + builder.testnet_with_host(host); + builder.pkarr(|client| client.no_dht()); + builder.build() + } } .expect("Pubky HTTP client construction must succeed for Pubky runtime composition") } pub(super) fn build_pubky_client(network: PubkyNetwork) -> pubky::Pubky { - match pubky_client_constructor(network) { - PubkyClientConstructor::Mainnet => pubky::Pubky::new(), - PubkyClientConstructor::Testnet => pubky::Pubky::testnet(), - } - .expect("Pubky client construction must succeed for legacy connect flows") + pubky::Pubky::with_client(build_pubky_http_client(network)) } pub(super) fn pubky_auth_relay_for_network(network: PubkyNetwork) -> Option { diff --git a/locks-server/src/app_state/test_support.rs b/locks-server/src/app_state/test_support.rs index 94aa3c4..963b572 100644 --- a/locks-server/src/app_state/test_support.rs +++ b/locks-server/src/app_state/test_support.rs @@ -8,8 +8,7 @@ use sqlx::postgres::PgPoolOptions; use time::macros::datetime; use crate::app_state::pubky_clients::{ - PubkyClientConstructor, PubkyHttpClientConstructor, pubky_auth_relay_for_network, - pubky_client_constructor, pubky_http_client_constructor, + PubkyHttpClientConstructor, pubky_auth_relay_for_network, pubky_http_client_constructor, }; use crate::app_state::{AppState, OsRandomTaskIdGenerator, RuntimeStorageKind}; use crate::config::{ @@ -205,18 +204,6 @@ async fn postgres_state_has_rate_limiter_configured_from_runtime_config() { ); } -#[test] -fn pubky_client_constructor_follows_configured_network() { - assert_eq!( - pubky_client_constructor(PubkyNetwork::Mainnet), - PubkyClientConstructor::Mainnet - ); - assert_eq!( - pubky_client_constructor(PubkyNetwork::Testnet), - PubkyClientConstructor::Testnet - ); -} - #[test] fn pubky_http_client_constructor_follows_configured_network() { assert_eq!( @@ -225,7 +212,7 @@ fn pubky_http_client_constructor_follows_configured_network() { ); assert_eq!( pubky_http_client_constructor(PubkyNetwork::Testnet), - PubkyHttpClientConstructor::Testnet + PubkyHttpClientConstructor::Testnet("127.0.0.1") ); } diff --git a/locks-server/src/pkdns.rs b/locks-server/src/pkdns.rs index ae9f950..9871f65 100644 --- a/locks-server/src/pkdns.rs +++ b/locks-server/src/pkdns.rs @@ -120,7 +120,11 @@ pub fn create_signed_packet( let mut svcb = SVCB::new(10, root_name.clone()); if let Some(port) = config.public_icann_http_port { let http_port_be_bytes = port.to_be_bytes(); - if domain == "localhost" { + if domain == "localhost" + || domain + .parse::() + .is_ok_and(|address| address.is_loopback()) + { svcb.set_param(SVCParam::Unknown( pubky_common::constants::reserved_param_keys::HTTP_PORT, Cow::Owned(http_port_be_bytes.to_vec()), @@ -212,6 +216,39 @@ mod tests { })); } + #[test] + fn create_signed_packet_publishes_http_port_for_loopback_ip_domain() { + let keypair = Keypair::from_secret(&[9_u8; 32]); + let config = PkdnsConfig { + public_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + public_pubky_tls_port: Some(6287), + public_icann_http_port: Some(3000), + icann_domain: Some("127.0.0.1".to_owned()), + pkarr_relays: Vec::new(), + key_republisher_interval_seconds: 3600, + }; + + let packet = create_signed_packet(&config, &keypair).unwrap(); + let http_endpoint = packet + .all_resource_records() + .find_map(|record| match &record.rdata { + RData::HTTPS(https) if https.0.target.to_string().contains("127.0.0.1") => { + Some(https) + } + _ => None, + }) + .unwrap(); + + assert!(matches!( + http_endpoint + .0 + .iter_params() + .find(|param| param.key_code() + == pubky_common::constants::reserved_param_keys::HTTP_PORT), + Some(SVCParam::Unknown(_, value)) if value.as_ref() == 3000_u16.to_be_bytes() + )); + } + #[test] fn load_lock_server_keypair_rejects_secret_seed_that_does_not_match_configured_public_key() { let temp_dir = tempfile::tempdir().unwrap();