From fb3d0a7e1a559cfe23ad09f3dec42e65d2574c2e Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 1 Oct 2026 18:08:14 +0300 Subject: [PATCH 01/24] feat(bind): an app that binds a GitHub identity on a local chain Runs the CCDP ceremony against the real Bridge, notary and CCDP, encodes the accepted proof as the GitHub verifier's TlsNotaryProof payload, and submits IdentityRegistry.bind from the user's wallet. It then reads the binding back from the registry and from the indexer. stack.ts starts anvil with the real libID-contracts v0.15.0 stack deployed by libid-deploy, a notary signing with the key that stack trusts, Bridge, CCDP and the usernames indexer. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/bind/.gitignore | 1 + ts/apps/bind/bridge-config.toml | 15 +++ ts/apps/bind/compose.yaml | 113 +++++++++++++++++++++ ts/apps/bind/local-dev.toml | 92 +++++++++++++++++ ts/apps/bind/package.json | 28 ++++++ ts/apps/bind/src/app.ts | 171 ++++++++++++++++++++++++++++++++ ts/apps/bind/src/evm.ts | 82 +++++++++++++++ ts/apps/bind/src/index.html | 40 ++++++++ ts/apps/bind/stack.ts | 162 ++++++++++++++++++++++++++++++ ts/apps/bind/tsconfig.json | 10 ++ ts/apps/bind/vite.config.ts | 19 ++++ ts/pnpm-lock.yaml | 40 ++++++++ 12 files changed, 773 insertions(+) create mode 100644 ts/apps/bind/.gitignore create mode 100644 ts/apps/bind/bridge-config.toml create mode 100644 ts/apps/bind/compose.yaml create mode 100644 ts/apps/bind/local-dev.toml create mode 100644 ts/apps/bind/package.json create mode 100644 ts/apps/bind/src/app.ts create mode 100644 ts/apps/bind/src/evm.ts create mode 100644 ts/apps/bind/src/index.html create mode 100644 ts/apps/bind/stack.ts create mode 100644 ts/apps/bind/tsconfig.json create mode 100644 ts/apps/bind/vite.config.ts diff --git a/ts/apps/bind/.gitignore b/ts/apps/bind/.gitignore new file mode 100644 index 00000000..ceddaa37 --- /dev/null +++ b/ts/apps/bind/.gitignore @@ -0,0 +1 @@ +.cache/ diff --git a/ts/apps/bind/bridge-config.toml b/ts/apps/bind/bridge-config.toml new file mode 100644 index 00000000..180cee76 --- /dev/null +++ b/ts/apps/bind/bridge-config.toml @@ -0,0 +1,15 @@ +ccdp_origin = "http://localhost:4683" +allowed_app_origins = ["http://localhost:4695"] + +[[platforms]] +id = "google" +client_id = "391814431594-94274lch0aosjgsei87k41a08napunhd.apps.googleusercontent.com" + +[[platforms]] +id = "x" +client_id = "QW5QY1ZGdVRsaDEyTFIwZDVfa2Q6MTpjaQ" + +[[platforms]] +id = "github" +client_id = "Iv23lioEM9NAR9vO8CmT" +client_credential = "b020b671192879cb993d1cdd7852c920fe745020" diff --git a/ts/apps/bind/compose.yaml b/ts/apps/bind/compose.yaml new file mode 100644 index 00000000..de0d1071 --- /dev/null +++ b/ts/apps/bind/compose.yaml @@ -0,0 +1,113 @@ +# Started by stack.ts after building CCDP. A local chain with the real libID +# contracts, the services a ceremony needs, and an indexer for the UI to read. +services: + anvil: + # foundry:v1.7.1 by index digest. Wall-clock block time: proofs carry + # real notary timestamps, and the verifiers bound their age by it. + image: ghcr.io/foundry-rs/foundry@sha256:8347b728d5d393dac1c018691b36f506d23b9dcd78341d40ea0fcb11c3a19cdd + entrypoint: ["anvil", "--host", "0.0.0.0", "--chain-id", "31337"] + ports: + - "127.0.0.1:4688:8545" + healthcheck: + test: ["CMD", "cast", "block-number", "--rpc-url", "http://127.0.0.1:8545"] + interval: 1s + timeout: 2s + retries: 30 + + notary: + # notary:0.4.0 by index digest. + image: ghcr.io/libid-org/notary@sha256:64716b57a89b6b9e5f97f8908572f4f8901b16ba4a0d442a44129020a70f5878 + environment: + NOTARY_HOST: 0.0.0.0 + NOTARY_CLIENT_IP_HEADER: peer + NOTARY_LIMITS_STORE: memory + NOTARY_MAX_SESSIONS_PER_IP: "0" + NOTARY_PER_IP_UPGRADES: "" + NOTARY_PER_IP_BYTES: "" + # anvil account #1, the signer chain-configurations' local-dev network + # trusts. A public test key, never a production notary identity. + SIGNING_KEY: "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + ports: + - "127.0.0.1:4687:7048" + # Shared namespace lets Bridge retrieve CCDP through localhost. + - "127.0.0.1:4682:8722" + - "127.0.0.1:4683:4683" + + bridge: + # libid-bridge-rs:0.5.0 by index digest. + image: ghcr.io/libid-org/libid-bridge-rs@sha256:e5fcb7a8ef26c4e372302206a1bdb3d0aeb183097a047727210721c7bd386757 + network_mode: service:notary + depends_on: + ccdp: + condition: service_healthy + notary: + condition: service_healthy + command: ["--config", "/bridge.toml"] + configs: + - source: bridge + target: /bridge.toml + + ccdp: + network_mode: service:notary + command: ["--port", "4683"] + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:4683/health"] + interval: 1s + timeout: 2s + retries: 30 + # static-web-server:3.0.0-beta.1-alpine by index digest. + image: ghcr.io/static-web-server/static-web-server@sha256:4e804280b5b5b1be4563d4a9e0f3a0ea38e7887967d0cc00bc8c07030f529b3f + environment: + SERVER_CONFIG_FILE: /etc/sws.toml + volumes: + - .cache/ccdp/public:/home/sws/public:ro + - .cache/ccdp/sws.toml:/etc/sws.toml:ro + + postgres: + # postgres:16 by index digest. No volume: every run starts empty. + image: postgres@sha256:1a6ab3f5345eb6dbe04a1349529caabdb0ab09293a09590fad07b2246bfa4b54 + environment: + POSTGRES_DB: usernames + POSTGRES_USER: usernames + POSTGRES_PASSWORD: usernames_dev + healthcheck: + test: ["CMD-SHELL", "pg_isready -U usernames -d usernames"] + interval: 1s + timeout: 2s + retries: 30 + + indexer: + # usernames-indexer:0.4.0 by index digest, which decodes contracts v0.15.0. + image: ghcr.io/libid-org/usernames-indexer@sha256:8d3ef1b4ae77531ba630fa177aaa1a09cf5177801d00409d0297176f171eeffb + environment: + DATABASE_URL: postgres://usernames:usernames_dev@postgres:5432/usernames + RPC_URL: http://anvil:8545 + IDENTITY_NAMES_ADDRESS: "0x0531b83b010a6b0c24c2c2c1a6beecc90cc71366" + CHAIN_NAMES: local + CHAIN_ID: "31337" + # anvil mines only when a transaction arrives: no confirmations to wait for. + CONFIRMATIONS: "0" + POLL_INTERVAL_SECS: "1" + depends_on: + postgres: + condition: service_healthy + anvil: + condition: service_healthy + + api: + # usernames-api:0.4.0 by index digest. + image: ghcr.io/libid-org/usernames-api@sha256:a2743fe0319a2057beee879dea140caf7ebb405e4be95a3cd9aa8a1516af6164 + environment: + DATABASE_URL: postgres://usernames:usernames_dev@postgres:5432/usernames + LISTEN_ADDR: 0.0.0.0:8080 + ports: + - "127.0.0.1:4689:8080" + depends_on: + postgres: + condition: service_healthy + indexer: + condition: service_started + +configs: + bridge: + file: ./bridge-config.toml diff --git a/ts/apps/bind/local-dev.toml b/ts/apps/bind/local-dev.toml new file mode 100644 index 00000000..84e9fda2 --- /dev/null +++ b/ts/apps/bind/local-dev.toml @@ -0,0 +1,92 @@ +# Local dev — the stack on a throwaway anvil, whether a docker-compose +# service or a bare process on a host or CI runner. +# +# `rpc_url` below is the compose service name, reachable only from inside +# that network. From anywhere else, keep this file as it is and name the +# endpoint on the command line: `--rpc-url` replaces the transport and +# nothing more — the chain id and every address declared here are the same +# either way. +# +# docker compose up -d anvil +# libid-deploy apply --network networks/local-dev.toml --yes \ +# --confirm-fresh-deploy --dev +# +# anvil --host 127.0.0.1 --port 8545 & +# libid-deploy apply --network networks/local-dev.toml \ +# --rpc-url http://127.0.0.1:8545 --yes --confirm-fresh-deploy --dev +# +# Anvil ships the CREATE2 deployer predeployed, so the factory bootstrap is +# one transaction; start it with `--disable-default-create2-deployer` to +# rehearse the keyless install path instead. +# +# NOTHING HERE IS SECRET. The keys below are anvil's own deterministic test +# accounts, printed by every anvil on startup and published in foundry's +# documentation. They control nothing outside a throwaway chain. +# +# This network is deliberately absent from the `network` choice list in +# .github/workflows/apply.yml: it has no KMS key and its chain exists only +# inside a CI run. + +[network] +name = "local-dev" +chain_id = 31337 +# The endpoint this file's own environment reaches: the compose service +# name. Every other caller passes `--rpc-url` and this value is not read; +# nothing else in the file depends on it. +rpc_url = "http://anvil:8545" + +[aws] +# Never read on this network. The apply workflow parses it to pick a region +# for the OIDC role assumption, and that workflow does not list local-dev; +# `apply` itself takes credentials from the ambient AWS environment, which +# the local signer below never reaches. +region = "local" +# The default signer. `--signer` specs are classified by SHAPE: 64 hex +# characters is a local private key, anything else is a KMS key id — so +# this is anvil account #0's key, and no AWS call happens. On a real +# network this is an `alias/...` name and the key never leaves KMS. +kms_deployer = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +# The ADDRESSES OF KEYS, not contracts. +[accounts] +# Anvil account #1. Separate from the deployer on purpose: the notary +# signer and the contract owner are different roles, and a local stack that +# collapses them hides a wiring mistake that a real deployment would show. +notary = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" +# Anvil account #0 — the same identity as the deployer above, named +# explicitly so the ownership handover is exercised rather than defaulted. +# On a dev chain apply completes the Ownable2Step acceptance by +# impersonation, so the stack ends fully owned. +owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + +[notary_service] +# 0.001 native. NOT zero on purpose: a local stack that meters at no charge +# lets a client attaching the wrong value pass, and `WrongValue` is then +# first seen on a network where it costs something. +fee_wei = "1000000000000000" + +# OUTPUT: the canonical contract addresses — identical on every EVM chain, +# this one included. Do not hand-edit; regenerate with +# `libid-deploy plan --print-addresses`. +[contracts] +factory = "0xa92244c3f4462aad08bd1a33c3940b9b936321ad" +notary_service = "0xbb5871167b0128939cab6850877981421e8dcbf5" +ceremony_proof_verifier = "0x76bdc18f21c2db0ff796c7cc50348528b2899275" +identity_registry = "0x0531b83b010a6b0c24c2c2c1a6beecc90cc71366" +# Initialized with identity_registry above, which it resolves every handle +# through for life: no setter moves it. +handle_escrow = "0xf7e3ad279f913ffe2ef74614e3046c15cbdabb9a" +google_jwt_roots = "0xb7a2ce28e71dbb9c877d2b5a48de33b5f0e6838d" +x_platform_verifier = "0xcfc880f62f2744dc000687edf47a98b585d9eb35" +github_platform_verifier = "0xac878389da7a1b58826182da0d8b4cae5e6e4178" +google_platform_verifier = "0xf3d537022362d187715b28bc547f8b2532e6d0cf" + +# The ceremony circuit verifiers are NOT declared here. `libid-deploy` +# carries them: `libid-contracts` embeds each circuit's bb-generated +# UltraHonk verifier from the libid-circuits release it pins, and apply +# deploys it through the factory under a CREATE3 name carrying that +# release and reads its code hash off the chain for the Platform Verifier +# to pin. Moving the contracts pin to a new circuits release and +# re-applying is the rotation path — a new release is a new name, a new +# address and a `setTrustRoots`, with the proxy and its registration +# untouched. diff --git a/ts/apps/bind/package.json b/ts/apps/bind/package.json new file mode 100644 index 00000000..3d86326f --- /dev/null +++ b/ts/apps/bind/package.json @@ -0,0 +1,28 @@ +{ + "name": "@libid/bind", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "An application that binds a platform identity on a local chain, end to end.", + "license": "(MIT OR Apache-2.0)", + "scripts": { + "dev": "pnpm build:deps && node stack.ts --app", + "dev:stack": "pnpm build:deps && node stack.ts", + "build:deps": "pnpm --filter '@libid/bind^...' build", + "build": "vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@libid/ceremony": "workspace:^", + "@libid/contracts": "0.15.0", + "@libid/ledger": "workspace:^", + "@libid/popup": "workspace:^", + "viem": "^2.48.11" + }, + "devDependencies": { + "@playwright/test": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/ts/apps/bind/src/app.ts b/ts/apps/bind/src/app.ts new file mode 100644 index 00000000..1f9e277b --- /dev/null +++ b/ts/apps/bind/src/app.ts @@ -0,0 +1,171 @@ +import { type Ceremony, CeremonyStage, createCCDPClient, type IdentityResult } from '@libid/ceremony' +import { identityRegistryAbi, platformId, resolveId } from '@libid/contracts' +import { PopupWindow } from '@libid/popup' +import { + type Address, + createPublicClient, + createWalletClient, + custom, + defineChain, + type EIP1193Provider, + hexToBytes, + http, + type WalletClient, +} from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { authorizedTransactionData, encodeTlsNotaryPayload, evmLedger } from './evm.ts' + +declare global { + interface Window { + ethereum?: EIP1193Provider + } +} + +const RPC_URL = import.meta.env.VITE_RPC_URL ?? 'http://127.0.0.1:4688' +const REGISTRY: Address = '0x0531b83b010a6b0c24c2c2c1a6beecc90cc71366' +const BRIDGE = 'http://localhost:4682' +const NOTARY = 'http://localhost:4687' +const chain = defineChain({ + id: 31337, + name: 'Local', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: [RPC_URL] } }, +}) +const publicClient = createPublicClient({ chain, transport: http(RPC_URL) }) +const registry = { client: publicClient, address: REGISTRY } +const ledger = evmLedger(chain.id, NOTARY) + +const field = (id: string) => document.querySelector(`[data-testid="${id}"]`)! +const show = (id: string, text: string) => { + field(id).textContent = text +} +const status = (text: string) => show('status', text) +const connectButton = document.querySelector('#connect')! +const bindAnchor = document.querySelector('#bind-github')! + +let wallet: WalletClient | undefined +let holder: Address | undefined + +/** An injected wallet if the page has one; otherwise the development key, for manual runs. */ +async function connect() { + if (window.ethereum) { + wallet = createWalletClient({ chain, transport: custom(window.ethereum) }) + ;[holder] = await wallet.requestAddresses() + } else { + const key = import.meta.env.VITE_DEV_PRIVATE_KEY as `0x${string}` | undefined + if (!key) throw new Error('No wallet: install one, or set VITE_DEV_PRIVATE_KEY for local runs.') + const account = privateKeyToAccount(key) + wallet = createWalletClient({ account, chain, transport: http(RPC_URL) }) + holder = account.address + } + show('holder', holder) +} + +async function submit(result: Extract, { status: 'accepted' }>, transactionData: Uint8Array, operationDomain: Uint8Array) { + if (!wallet || !holder) throw new Error('Wallet disconnected') + show('proof-received', `${result.identity.userName} (${result.identity.userId})`) + const github = platformId('github') + const value = await publicClient.readContract({ + address: REGISTRY, + abi: identityRegistryAbi, + functionName: 'quoteBind', + args: [github, result.oauthProof.platformCeremonyVersion], + }) + const payload = encodeTlsNotaryPayload(result.oauthProof, operationDomain, transactionData) + status('Submitting the binding…') + const { request } = await publicClient.simulateContract({ + account: holder, + address: REGISTRY, + abi: identityRegistryAbi, + functionName: 'bind', + args: [github, result.oauthProof.platformCeremonyVersion, payload, true], + value, + }) + const hash = await wallet.writeContract({ ...request, account: wallet.account ?? holder }) + show('tx-hash', hash) + const receipt = await publicClient.waitForTransactionReceipt({ hash }) + show('receipt-status', `${receipt.status} in block ${receipt.blockNumber}`) + if (receipt.status !== 'success') throw new Error('The binding transaction reverted') + + const bound = await resolveId(registry, github, result.identity.userId) + show('registry-holder', bound ?? 'not bound') + status('Bound on chain. Waiting for the indexer…') + show('indexer-holder', await indexed(result.identity.userId)) + status('Done.') +} + +/** The holder the indexer reports for a GitHub id, polled until it appears. */ +async function indexed(userId: string): Promise { + for (let attempt = 0; attempt < 60; attempt++) { + const response = await fetch(`/indexer/v1/resolve/id/github/${encodeURIComponent(userId)}`) + if (response.ok) { + const body = (await response.json()) as { bindings: { chainId: number; owner: string }[] } + const local = body.bindings.find((binding) => binding.chainId === chain.id) + if (local) return local.owner + } + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + return 'not indexed after 60 s' +} + +async function initialize() { + const client = await createCCDPClient({ oauthBridge: BRIDGE }) + if (!client.enabledPlatforms.includes('github')) { + status('The Bridge does not enable GitHub.') + return + } + const operationDomain = hexToBytes( + await publicClient.readContract({ address: REGISTRY, abi: identityRegistryAbi, functionName: 'OPERATION_DOMAIN' }), + ) + connectButton.addEventListener('click', () => { + connect().then( + () => { + bindAnchor.setAttribute('aria-disabled', 'false') + status('Ready. Click Bind GitHub.') + }, + (error: unknown) => status(error instanceof Error ? error.message : 'Could not connect a wallet.'), + ) + }) + bindAnchor.addEventListener('click', (event) => { + if (!holder) { + event.preventDefault() + return + } + const id = crypto.randomUUID() + const target = `ceremony-${id}` + // Window creation stays synchronous with the click. + const popup = PopupWindow.open(target) + const connection = client.connect(popup, { connectionId: id }) + const transactionData = authorizedTransactionData(holder) + let ceremony: Ceremony<'github'> + try { + ceremony = client.new(connection, id, 'github', ledger, operationDomain, transactionData) + } catch (error) { + event.preventDefault() + connection.close() + status(error instanceof Error ? error.message : 'Unable to start the ceremony.') + return + } + bindAnchor.target = target + bindAnchor.href = ceremony.launchUrl + if (popup.opened) event.preventDefault() + const off = ceremony.onStage((update) => { + show('stage', update.status === 'active' ? CeremonyStage.message(update.stage, 'GitHub') : update.status) + }) + void ceremony + .proveUserIdentity() + .then(async (result) => { + connection.close() + if (result.status !== 'accepted') { + status('GitHub denied the authorization.') + return + } + await submit(result, transactionData, operationDomain) + }) + .catch((error: unknown) => status(error instanceof Error ? error.message : 'The binding failed.')) + .finally(off) + }) + status('Connect a wallet to start.') +} + +void initialize().catch(() => status('Could not load the Bridge configuration. Is the stack running?')) diff --git a/ts/apps/bind/src/evm.ts b/ts/apps/bind/src/evm.ts new file mode 100644 index 00000000..b12cd4bc --- /dev/null +++ b/ts/apps/bind/src/evm.ts @@ -0,0 +1,82 @@ +// What an application needs to turn an accepted ceremony into an EVM `bind` +// transaction. Nothing here is published by libID yet. +import type { OAuthProof } from '@libid/ceremony' +import type { LedgerId } from '@libid/ledger' +import { + type Address, + bytesToHex, + encodeAbiParameters, + type Hex, + hexToBytes, + keccak256, + numberToHex, + zeroAddress, +} from 'viem' + +/** An EVM ledger: the Chain ID of the EVM Chain Profile, and the notary to route through. */ +export function evmLedger(chainId: number | bigint, notaryUrl: string): LedgerId { + // specs/chain-profiles.md §3.1: keccak256 of the 32-byte big-endian EIP-155 id. + const hash = hexToBytes(keccak256(numberToHex(BigInt(chainId), { size: 32 }))) + return { hash: () => hash.slice(), notaryAddress: () => notaryUrl } +} + +/** The Authorized Transaction Data of `bind`: the holder, and no service fee. */ +export function authorizedTransactionData(holder: Address): Uint8Array { + return hexToBytes( + encodeAbiParameters( + [{ type: 'address' }, { type: 'uint256' }, { type: 'address' }], + [holder, 0n, zeroAddress], + ), + ) +} + +const attestation = [ + { name: 'attestedData', type: 'bytes' }, + { name: 'proof', type: 'bytes' }, +] as const + +/** `TlsNotaryVerifierBase.TlsNotaryProof`, the payload GitHub and X verifiers decode. */ +const tlsNotaryProof = [ + { + type: 'tuple', + components: [ + { name: 'ceremonyVersion', type: 'uint16' }, + { name: 'operationDomain', type: 'bytes32' }, + { name: 'authorizationNonce', type: 'bytes32' }, + { name: 'transactionData', type: 'bytes' }, + { name: 'tokenSession', type: 'tuple', components: attestation }, + { name: 'identitySession', type: 'tuple', components: attestation }, + { name: 'proof', type: 'bytes' }, + ], + }, +] as const + +/** + * The `payload` of `IdentityRegistry.bind` for a GitHub or X ceremony. The + * operation domain and transaction data are the ones the ceremony was started + * with; the proof does not carry them. + */ +export function encodeTlsNotaryPayload( + oauthProof: OAuthProof<'github' | 'x'>, + operationDomain: Uint8Array, + transactionData: Uint8Array, +): Hex { + const { proof } = oauthProof + return encodeAbiParameters(tlsNotaryProof, [ + { + ceremonyVersion: oauthProof.platformCeremonyVersion, + operationDomain: bytesToHex(operationDomain), + authorizationNonce: bytesToHex(oauthProof.authorizationNonce), + transactionData: bytesToHex(transactionData), + tokenSession: { + attestedData: bytesToHex(proof.tokenAttestation.attestedData), + proof: bytesToHex(proof.tokenAttestation.signature), + }, + identitySession: { + attestedData: bytesToHex(proof.identityAttestation.attestedData), + proof: bytesToHex(proof.identityAttestation.signature), + }, + proof: bytesToHex(proof.bearerLinkProof), + }, + ]) +} diff --git a/ts/apps/bind/src/index.html b/ts/apps/bind/src/index.html new file mode 100644 index 00000000..18267da6 --- /dev/null +++ b/ts/apps/bind/src/index.html @@ -0,0 +1,40 @@ + + + + + + Bind an identity + + + +

Bind an identity

+

Prove a GitHub account in the browser, then bind it on the local chain.

+
+ + Bind GitHub +
+

+
+
Wallet
—
+
Stage
—
+
Proved account
—
+
Transaction
—
+
Receipt
—
+
Registry says
—
+
Indexer says
—
+
+ + + diff --git a/ts/apps/bind/stack.ts b/ts/apps/bind/stack.ts new file mode 100644 index 00000000..adcf3f47 --- /dev/null +++ b/ts/apps/bind/stack.ts @@ -0,0 +1,162 @@ +// Start a local chain with the real libID contracts, the notary, Bridge, CCDP +// and the indexer; with --app, also the bind application. No OAuth mocks. +import { execFileSync, spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { fileURLToPath } from 'node:url' +import { identityRegistryAbi, platformId } from '@libid/contracts' +import { createPublicClient, http } from 'viem' +import { createServer as createViteServer, type ViteDevServer } from 'vite' + +const root = fileURLToPath(new URL('.', import.meta.url)) +const cache = join(root, '.cache') +const project = `libid-bind-${createHash('sha256').update(root).digest('hex').slice(0, 12)}` +const composeArgs = ['compose', '-p', project, '-f', join(root, 'compose.yaml')] + +export const RPC_URL = 'http://127.0.0.1:4688' +export const REGISTRY = '0x0531b83b010a6b0c24c2c2c1a6beecc90cc71366' as const +const BRIDGE = 'http://127.0.0.1:4682' +const API = 'http://127.0.0.1:4689' +const APP_ORIGIN = 'http://localhost:4695' + +/** chain-configurations' deploy tool; it deploys the real verifiers, not stubs. */ +const DEPLOY = { + version: '0.14.0', + asset: 'libid-deploy-0.14.0-x86_64-unknown-linux-gnu', + sha256: '131166dcc90dcbcbb9b10d471310ed34db30062328a5e5e03789d9636b61dc9c', +} + +async function rpc(method: string, params: unknown[]): Promise { + const response = await fetch(RPC_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: AbortSignal.timeout(2000), + }) + const body = (await response.json()) as { result?: unknown; error?: { message: string } } + if (body.error) throw new Error(body.error.message) + return body.result +} + +async function until(what: string, check: () => Promise, seconds = 120) { + const deadline = Date.now() + seconds * 1000 + while (Date.now() < deadline) { + try { + if (await check()) return + } catch { + /* not up yet */ + } + await delay(500) + } + throw new Error(`Timed out waiting for ${what}`) +} + +async function deployTool(): Promise { + const binary = join(cache, DEPLOY.asset, 'libid-deploy') + if (existsSync(binary)) return binary + mkdirSync(cache, { recursive: true }) + const url = `https://github.com/libid-org/chain-configurations/releases/download/v${DEPLOY.version}/${DEPLOY.asset}.tar.gz` + const archive = Buffer.from(await (await fetch(url)).arrayBuffer()) + const digest = createHash('sha256').update(archive).digest('hex') + if (digest !== DEPLOY.sha256) throw new Error(`libid-deploy archive digest ${digest} is not the pinned one`) + const path = join(cache, `${DEPLOY.asset}.tar.gz`) + writeFileSync(path, archive) + execFileSync('tar', ['xzf', path, '-C', cache]) + return binary +} + +/** Deploy the stack to the fresh anvil, then check what the app relies on. */ +async function deploy() { + const tool = await deployTool() + execFileSync( + tool, + ['apply', '--network', join(root, 'local-dev.toml'), '--rpc-url', RPC_URL, '--yes', '--confirm-fresh-deploy', '--dev'], + { stdio: 'inherit' }, + ) + const chain = createPublicClient({ transport: http(RPC_URL) }) + // Two notary fees: the GitHub verifier is registered and the notary service wired. + const quote = await chain.readContract({ + address: REGISTRY, + abi: identityRegistryAbi, + functionName: 'quoteBind', + args: [platformId('github'), 1], + }) + if (quote !== 2_000_000_000_000_000n) throw new Error(`quoteBind(github, 1) is ${quote}, expected 2e15`) + const code = (await rpc('eth_getCode', [REGISTRY, 'latest'])) as string + if (code === '0x') throw new Error('IdentityRegistry has no code after deploy') +} + +async function ready() { + await until('Bridge', async () => { + const response = await fetch(`${BRIDGE}/api/v1/ceremony/config`, { + headers: { Origin: APP_ORIGIN }, + redirect: 'error', + signal: AbortSignal.timeout(1000), + }) + await response.body?.cancel() + return response.status === 200 + }) + await until('indexer API', async () => { + const response = await fetch(`${API}/v1/status`, { signal: AbortSignal.timeout(1000) }) + await response.body?.cancel() + return response.status === 200 + }) +} + +// ─── Main ─────────────────────────────────────────────────────── + +execFileSync( + 'pnpm', + ['--filter', '@libid/ceremony', 'build:ccdp-artifacts', '--out-dir', join(cache, 'ccdp')], + { cwd: root, stdio: 'inherit' }, +) + +let frontend: ViteDevServer | undefined +let stopping = false +function stop(code: number) { + if (stopping) return + stopping = true + process.exitCode = code + void frontend?.close() + if (compose.pid) { + try { + process.kill(-compose.pid, 'SIGTERM') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error + } + } + const down = spawn('docker', [...composeArgs, 'down', '--volumes'], { stdio: 'inherit' }) + down.on('close', (code) => { + if (code !== 0) process.exitCode = 1 + }) +} +process.once('SIGINT', () => stop(0)) +process.once('SIGTERM', () => stop(0)) +const compose = spawn('docker', [...composeArgs, 'up', '--abort-on-container-failure'], { + stdio: 'inherit', + detached: true, +}) +compose.on('error', () => { + console.error('Could not start Docker Compose. Install Docker with Compose and start its engine.') + stop(1) +}) +compose.on('exit', (code) => { + if (!stopping) stop(code || 1) +}) + +try { + await until('anvil', async () => (await rpc('eth_chainId', [])) === '0x7a69') + await deploy() + await ready() + console.info(`Stack ready: chain ${RPC_URL}, registry ${REGISTRY}, indexer ${API}`) + if (process.argv.includes('--app') && !stopping) { + frontend = await createViteServer({ configFile: join(root, 'vite.config.ts') }) + await frontend.listen() + frontend.printUrls() + } +} catch (error) { + console.error(error instanceof Error ? error.message : error) + stop(1) +} diff --git a/ts/apps/bind/tsconfig.json b/ts/apps/bind/tsconfig.json new file mode 100644 index 00000000..c2991a1a --- /dev/null +++ b/ts/apps/bind/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "noEmit": true, + "allowImportingTsExtensions": true, + "types": ["node", "vite/client"] + }, + "include": ["*.ts", "src"] +} diff --git a/ts/apps/bind/vite.config.ts b/ts/apps/bind/vite.config.ts new file mode 100644 index 00000000..52d9b93e --- /dev/null +++ b/ts/apps/bind/vite.config.ts @@ -0,0 +1,19 @@ +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' + +const root = fileURLToPath(new URL('.', import.meta.url)) +const directory = join(root, '.cache/bind') +export default defineConfig({ + root: join(root, 'src'), + cacheDir: join(directory, 'vite'), + envDir: root, + server: { + host: 'localhost', + port: 4695, + strictPort: true, + // The indexer's read API, same-origin so the page needs no CORS from it. + proxy: { '/indexer': { target: 'http://127.0.0.1:4689', rewrite: (path) => path.replace(/^\/indexer/, '') } }, + }, + build: { outDir: join(directory, 'app'), emptyOutDir: true }, +}) diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index 83d1c918..ae297c77 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -36,6 +36,37 @@ importers: specifier: ^1.86.0 version: 1.86.0 + apps/bind: + dependencies: + '@libid/ceremony': + specifier: workspace:^ + version: link:../../packages/ceremony + '@libid/contracts': + specifier: 0.15.0 + version: 0.15.0(viem@2.55.13(typescript@5.9.3)) + '@libid/ledger': + specifier: workspace:^ + version: link:../../packages/ledger + '@libid/popup': + specifier: workspace:^ + version: link:../../packages/popup + viem: + specifier: ^2.48.11 + version: 2.55.13(typescript@5.9.3) + devDependencies: + '@playwright/test': + specifier: 'catalog:' + version: 1.64.0-alpha-2026-09-29 + '@types/node': + specifier: 'catalog:' + version: 22.20.1 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: 'catalog:' + version: 7.3.6(@types/node@22.20.1) + apps/demo: dependencies: '@libid/claim': @@ -583,6 +614,11 @@ packages: peerDependencies: viem: ^2.48.11 + '@libid/contracts@0.15.0': + resolution: {integrity: sha512-TOthZejhxj7NdY2Fr6dwgt+TVGEPuTSVSBcTJIvK7ZMQqJY4Ugwq9av20lfbj2qse9qbIVsknFRUr8QFfDfhoQ==} + peerDependencies: + viem: ^2.48.11 + '@libid/contracts@0.2.0': resolution: {integrity: sha512-Z+qSdBRtNxRY/qn+xIYcZtT4ZTrzooGMJoDNXdCYS66NxcsrGrPq4FKV1AiksO48D8F7J6VBp4e9I1LZwB7hKA==} peerDependencies: @@ -1926,6 +1962,10 @@ snapshots: dependencies: viem: 2.55.13(typescript@5.9.3) + '@libid/contracts@0.15.0(viem@2.55.13(typescript@5.9.3))': + dependencies: + viem: 2.55.13(typescript@5.9.3) + '@libid/contracts@0.2.0(viem@2.55.13(typescript@5.9.3))': dependencies: viem: 2.55.13(typescript@5.9.3) From 0f213b85e8cc4a1334d91c99ee7133e4ccc11383 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 1 Oct 2026 18:42:37 +0300 Subject: [PATCH 02/24] test(bind): bind a real GitHub identity end to end in Playwright The live test signs in to GitHub with the test account (password and TOTP, ported from libid-server-rs' live ceremony suite), lets the ceremony notarize and prove in the browser, has an injected EIP-1193 wallet backed by an unlocked anvil account submit bind, and checks the transaction, the registry and the indexer through the page. Anvil mines a block every second so block time follows the clock: a proof's notary timestamp is checked against it. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/bind/.gitignore | 1 + ts/apps/bind/compose.yaml | 7 +- ts/apps/bind/e2e/bind.live.spec.ts | 29 +++++++ ts/apps/bind/e2e/github.ts | 100 +++++++++++++++++++++++++ ts/apps/bind/e2e/wallet.ts | 29 +++++++ ts/apps/bind/package.json | 6 +- ts/apps/bind/playwright.live.config.ts | 29 +++++++ ts/apps/bind/tsconfig.json | 2 +- ts/pnpm-lock.yaml | 10 +++ 9 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 ts/apps/bind/e2e/bind.live.spec.ts create mode 100644 ts/apps/bind/e2e/github.ts create mode 100644 ts/apps/bind/e2e/wallet.ts create mode 100644 ts/apps/bind/playwright.live.config.ts diff --git a/ts/apps/bind/.gitignore b/ts/apps/bind/.gitignore index ceddaa37..d40c2ab7 100644 --- a/ts/apps/bind/.gitignore +++ b/ts/apps/bind/.gitignore @@ -1 +1,2 @@ .cache/ +test-results/ diff --git a/ts/apps/bind/compose.yaml b/ts/apps/bind/compose.yaml index de0d1071..9130c447 100644 --- a/ts/apps/bind/compose.yaml +++ b/ts/apps/bind/compose.yaml @@ -2,10 +2,11 @@ # contracts, the services a ceremony needs, and an indexer for the UI to read. services: anvil: - # foundry:v1.7.1 by index digest. Wall-clock block time: proofs carry - # real notary timestamps, and the verifiers bound their age by it. + # foundry:v1.7.1 by index digest. A block every second keeps block time + # at the wall clock, as on a live chain: proofs carry real notary + # timestamps, and the verifiers refuse one dated ahead of the block. image: ghcr.io/foundry-rs/foundry@sha256:8347b728d5d393dac1c018691b36f506d23b9dcd78341d40ea0fcb11c3a19cdd - entrypoint: ["anvil", "--host", "0.0.0.0", "--chain-id", "31337"] + entrypoint: ["anvil", "--host", "0.0.0.0", "--chain-id", "31337", "--block-time", "1", "--silent"] ports: - "127.0.0.1:4688:8545" healthcheck: diff --git a/ts/apps/bind/e2e/bind.live.spec.ts b/ts/apps/bind/e2e/bind.live.spec.ts new file mode 100644 index 00000000..01460925 --- /dev/null +++ b/ts/apps/bind/e2e/bind.live.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test' +import { authorizeOnGitHub, githubAccount } from './github.ts' +import { injectWallet } from './wallet.ts' + +const RPC_URL = 'http://127.0.0.1:4688' +/** anvil account #2: unlocked by anvil, funded, and not one the deploy uses. */ +const HOLDER = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' + +test('a GitHub identity is proved in the browser, bound on chain, and shown', async ({ page, context }) => { + const account = githubAccount() + test.skip(!account, 'GH_TEST_ALICE_USERNAME, _PASSWORD and _TOTP_SECRET are not set') + + await injectWallet(context, RPC_URL, HOLDER) + await page.goto('/') + await expect(page.getByTestId('status')).toHaveText('Connect a wallet to start.') + + await page.getByRole('button', { name: 'Connect wallet' }).click() + await expect(page.getByTestId('holder')).toHaveText(HOLDER) + + const [popup] = await Promise.all([context.waitForEvent('page'), page.getByRole('button', { name: 'Bind GitHub' }).click()]) + await authorizeOnGitHub(popup, account!) + + // Proving runs in the popup's Prover document after the OAuth return. + await expect(page.getByTestId('proof-received')).toContainText(account!.username, { ignoreCase: true, timeout: 300_000 }) + await expect(page.getByTestId('tx-hash')).toHaveText(/^0x[0-9a-f]{64}$/, { timeout: 60_000 }) + await expect(page.getByTestId('receipt-status')).toHaveText(/^success in block \d+$/, { timeout: 60_000 }) + await expect(page.getByTestId('registry-holder')).toHaveText(HOLDER) + await expect(page.getByTestId('indexer-holder')).toHaveText(new RegExp(`^${HOLDER}$`, 'i'), { timeout: 90_000 }) +}) diff --git a/ts/apps/bind/e2e/github.ts b/ts/apps/bind/e2e/github.ts new file mode 100644 index 00000000..56a73499 --- /dev/null +++ b/ts/apps/bind/e2e/github.ts @@ -0,0 +1,100 @@ +import type { Page } from '@playwright/test' +import * as OTPAuth from 'otpauth' + +export interface GitHubAccount { + username: string + password: string + totpSecret: string +} + +/** The test account, from GH_TEST_ALICE_*; undefined when any is missing. */ +export function githubAccount(): GitHubAccount | undefined { + const username = process.env.GH_TEST_ALICE_USERNAME + const password = process.env.GH_TEST_ALICE_PASSWORD + const totpSecret = process.env.GH_TEST_ALICE_TOTP_SECRET + return username && password && totpSecret ? { username, password, totpSecret } : undefined +} + +function totp(secret: string): string { + const normalized = secret.replace(/\s/g, '').toUpperCase() + return new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(normalized), algorithm: 'SHA1', digits: 6, period: 30 }).generate() +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * Sign in and authorize on GitHub in `popup`, until it leaves github.com. A + * port of libid-server-rs' ceremony-tests/src/browser/github.rs: fill the login + * form once, answer the authenticator step once per 30-second code, approve the + * OAuth app, and back off on GitHub's error and rate-limit pages. + */ +export async function authorizeOnGitHub(popup: Page, account: GitHubAccount, budgetMs = 180_000) { + const started = Date.now() + let filled = false + let totpStep: number | undefined + let rateLimited = 0 + let errored = 0 + + while (Date.now() - started < budgetMs) { + if (popup.isClosed()) return + let url: URL + try { + url = new URL(popup.url()) + } catch { + await sleep(250) + continue + } + if (url.hostname !== 'github.com') { + // Before GitHub (Prefetch) or after it (Callback, Prover): not ours to drive. + if (filled || url.pathname.startsWith('/auth/callback')) return + await sleep(250) + continue + } + const text = ((await popup.locator('body').textContent().catch(() => '')) ?? '').toLowerCase() + if (text.includes("couldn't respond to your request in time") || text.includes('something went wrong')) { + errored += 1 + if (errored > 3) throw new Error('GitHub answered its error page three times') + await sleep(10_000 * errored) + await popup.goBack().catch(() => {}) + filled = false + totpStep = undefined + continue + } + const path = url.pathname + if (path === '/login' || path === '/session') { + if (text.includes('too many requests') || text.includes('rate limit')) { + rateLimited += 1 + if (rateLimited > 3) throw new Error('GitHub rate-limited the login three times') + await sleep(30_000 * rateLimited) + await popup.reload() + filled = false + continue + } + if (!filled && (await popup.locator('#login_field').count())) { + await popup.fill('#login_field', account.username) + await popup.fill('#password', account.password) + await popup.click('input[type=submit][name=commit]') + filled = true + } + } else if (path === '/sessions/two-factor/app') { + const step = Math.floor(Date.now() / 30_000) + if (totpStep !== step && (await popup.locator('#app_totp').count())) { + await popup.fill('#app_totp', totp(account.totpSecret)) + // GitHub submits the code on its own once six digits are in. + await popup.locator('button[type=submit]').click({ timeout: 2_000 }).catch(() => {}) + totpStep = step + } + } else if (path.startsWith('/sessions/two-factor')) { + await popup.goto('https://github.com/sessions/two-factor/app') + } else if (path === '/login/oauth/authorize') { + const authorize = popup.locator("button[name='authorize'][value='1']") + if (await authorize.count()) await authorize.click() + else if (text.includes('redirect_uri') || text.includes('be careful')) + throw new Error("GitHub refused the authorization request: the app's callback URL does not match") + } else if (path.startsWith('/sessions/verified-device')) { + throw new Error('GitHub asked for device verification: the account has no TOTP method') + } + await sleep(250) + } + throw new Error(`No GitHub authorization in ${budgetMs / 1000} s; stopped on ${popup.url()}`) +} diff --git a/ts/apps/bind/e2e/wallet.ts b/ts/apps/bind/e2e/wallet.ts new file mode 100644 index 00000000..9762f8df --- /dev/null +++ b/ts/apps/bind/e2e/wallet.ts @@ -0,0 +1,29 @@ +import type { BrowserContext } from '@playwright/test' + +/** + * An injected EIP-1193 wallet for the test page. It answers account requests + * with an account anvil keeps unlocked, and forwards everything else, signing + * included, to anvil, so no private key enters the page. + */ +export async function injectWallet(context: BrowserContext, rpcUrl: string, account: string) { + await context.addInitScript( + ({ rpcUrl, account }) => { + let id = 0 + const request = async ({ method, params }: { method: string; params?: unknown[] }) => { + if (method === 'eth_requestAccounts' || method === 'eth_accounts') return [account] + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params: params ?? [] }), + }) + const body = await response.json() + if (body.error) throw Object.assign(new Error(body.error.message), body.error) + return body.result + } + Object.defineProperty(window, 'ethereum', { + value: { request, on() {}, removeListener() {} }, + }) + }, + { rpcUrl, account }, + ) +} diff --git a/ts/apps/bind/package.json b/ts/apps/bind/package.json index 3d86326f..14f78142 100644 --- a/ts/apps/bind/package.json +++ b/ts/apps/bind/package.json @@ -10,7 +10,8 @@ "dev:stack": "pnpm build:deps && node stack.ts", "build:deps": "pnpm --filter '@libid/bind^...' build", "build": "vite build", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:live": "playwright test -c playwright.live.config.ts" }, "dependencies": { "@libid/ceremony": "workspace:^", @@ -23,6 +24,7 @@ "@playwright/test": "catalog:", "@types/node": "catalog:", "typescript": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "otpauth": "^9.4.0" } } diff --git a/ts/apps/bind/playwright.live.config.ts b/ts/apps/bind/playwright.live.config.ts new file mode 100644 index 00000000..ebdae983 --- /dev/null +++ b/ts/apps/bind/playwright.live.config.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs' +import { defineConfig, devices } from '@playwright/test' + +// Test secrets from a dotenv file when LIBID_TEST_ENV_FILE names one; values +// already in the environment win. Nothing is printed. +const envFile = process.env.LIBID_TEST_ENV_FILE +if (envFile) { + for (const line of readFileSync(envFile, 'utf8').split('\n')) { + const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line) + if (match && process.env[match[1]!] === undefined) process.env[match[1]!] = match[2]!.replace(/^(['"])(.*)\1$/, '$2') + } +} + +/** Live: real GitHub, real notary, real chain. Needs `pnpm dev` running. */ +export default defineConfig({ + testDir: 'e2e', + testMatch: '*.live.spec.ts', + workers: 1, + timeout: 10 * 60_000, + reporter: [['list']], + use: { + ...devices['Desktop Chrome'], + baseURL: 'http://localhost:4695', + headless: !process.env.HEADED, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, +}) diff --git a/ts/apps/bind/tsconfig.json b/ts/apps/bind/tsconfig.json index c2991a1a..a3090e41 100644 --- a/ts/apps/bind/tsconfig.json +++ b/ts/apps/bind/tsconfig.json @@ -6,5 +6,5 @@ "allowImportingTsExtensions": true, "types": ["node", "vite/client"] }, - "include": ["*.ts", "src"] + "include": ["*.ts", "src", "e2e"] } diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index ae297c77..ce3b88bd 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@types/node': specifier: 'catalog:' version: 22.20.1 + otpauth: + specifier: ^9.4.0 + version: 9.5.2 typescript: specifier: 'catalog:' version: 5.9.3 @@ -1360,6 +1363,9 @@ packages: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} + otpauth@9.5.2: + resolution: {integrity: sha512-GQ5emWR/x1tcExT62IBT0UfO95wZzJZyxYOJOGVeQF47SYEN9vmh0vISvDZaNMuFJRG+IaWCKtfm+t9Bfoal6w==} + ox@0.14.33: resolution: {integrity: sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ==} peerDependencies: @@ -2579,6 +2585,10 @@ snapshots: node-releases@2.0.53: {} + otpauth@9.5.2: + dependencies: + '@noble/hashes': 2.4.0 + ox@0.14.33(typescript@5.9.3): dependencies: '@adraffy/ens-normalize': 1.11.1 From 71c4ddc2048e027f2d1a99580f241ae34ba8dbf4 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 1 Oct 2026 20:04:51 +0300 Subject: [PATCH 03/24] fix(bind): pass the workspace lint and format checks Biome skips the app's build cache as it does apps/dev's. The Bind GitHub anchor drops role=button, so it reads as the link it is. The GitHub login loop is split into one handler per page, under oxlint's complexity limit. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/bind/e2e/bind.live.spec.ts | 23 +++- ts/apps/bind/e2e/github.ts | 159 +++++++++++++++---------- ts/apps/bind/playwright.live.config.ts | 3 +- ts/apps/bind/src/app.ts | 35 ++++-- ts/apps/bind/src/index.html | 2 +- ts/apps/bind/stack.ts | 17 ++- ts/apps/bind/vite.config.ts | 7 +- ts/biome.json | 3 +- 8 files changed, 168 insertions(+), 81 deletions(-) diff --git a/ts/apps/bind/e2e/bind.live.spec.ts b/ts/apps/bind/e2e/bind.live.spec.ts index 01460925..39978f37 100644 --- a/ts/apps/bind/e2e/bind.live.spec.ts +++ b/ts/apps/bind/e2e/bind.live.spec.ts @@ -6,7 +6,10 @@ const RPC_URL = 'http://127.0.0.1:4688' /** anvil account #2: unlocked by anvil, funded, and not one the deploy uses. */ const HOLDER = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' -test('a GitHub identity is proved in the browser, bound on chain, and shown', async ({ page, context }) => { +test('a GitHub identity is proved in the browser, bound on chain, and shown', async ({ + page, + context, +}) => { const account = githubAccount() test.skip(!account, 'GH_TEST_ALICE_USERNAME, _PASSWORD and _TOTP_SECRET are not set') @@ -17,13 +20,23 @@ test('a GitHub identity is proved in the browser, bound on chain, and shown', as await page.getByRole('button', { name: 'Connect wallet' }).click() await expect(page.getByTestId('holder')).toHaveText(HOLDER) - const [popup] = await Promise.all([context.waitForEvent('page'), page.getByRole('button', { name: 'Bind GitHub' }).click()]) + const [popup] = await Promise.all([ + context.waitForEvent('page'), + page.getByRole('link', { name: 'Bind GitHub' }).click(), + ]) await authorizeOnGitHub(popup, account!) // Proving runs in the popup's Prover document after the OAuth return. - await expect(page.getByTestId('proof-received')).toContainText(account!.username, { ignoreCase: true, timeout: 300_000 }) + await expect(page.getByTestId('proof-received')).toContainText(account!.username, { + ignoreCase: true, + timeout: 300_000, + }) await expect(page.getByTestId('tx-hash')).toHaveText(/^0x[0-9a-f]{64}$/, { timeout: 60_000 }) - await expect(page.getByTestId('receipt-status')).toHaveText(/^success in block \d+$/, { timeout: 60_000 }) + await expect(page.getByTestId('receipt-status')).toHaveText(/^success in block \d+$/, { + timeout: 60_000, + }) await expect(page.getByTestId('registry-holder')).toHaveText(HOLDER) - await expect(page.getByTestId('indexer-holder')).toHaveText(new RegExp(`^${HOLDER}$`, 'i'), { timeout: 90_000 }) + await expect(page.getByTestId('indexer-holder')).toHaveText(new RegExp(`^${HOLDER}$`, 'i'), { + timeout: 90_000, + }) }) diff --git a/ts/apps/bind/e2e/github.ts b/ts/apps/bind/e2e/github.ts index 56a73499..c4234ce0 100644 --- a/ts/apps/bind/e2e/github.ts +++ b/ts/apps/bind/e2e/github.ts @@ -17,11 +17,94 @@ export function githubAccount(): GitHubAccount | undefined { function totp(secret: string): string { const normalized = secret.replace(/\s/g, '').toUpperCase() - return new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(normalized), algorithm: 'SHA1', digits: 6, period: 30 }).generate() + return new OTPAuth.TOTP({ + secret: OTPAuth.Secret.fromBase32(normalized), + algorithm: 'SHA1', + digits: 6, + period: 30, + }).generate() } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) +/** What the login has done so far; each handler reads and updates it. */ +interface Progress { + filled: boolean + totpStep?: number + rateLimited: number + errored: number +} + +async function onErrorPage(popup: Page, progress: Progress) { + progress.errored += 1 + if (progress.errored > 3) throw new Error('GitHub answered its error page three times') + await sleep(10_000 * progress.errored) + await popup.goBack().catch(() => {}) + progress.filled = false + progress.totpStep = undefined +} + +async function onLogin(popup: Page, account: GitHubAccount, progress: Progress, text: string) { + if (text.includes('too many requests') || text.includes('rate limit')) { + progress.rateLimited += 1 + if (progress.rateLimited > 3) throw new Error('GitHub rate-limited the login three times') + await sleep(30_000 * progress.rateLimited) + await popup.reload() + progress.filled = false + return + } + if (progress.filled || !(await popup.locator('#login_field').count())) return + await popup.fill('#login_field', account.username) + await popup.fill('#password', account.password) + await popup.click('input[type=submit][name=commit]') + progress.filled = true +} + +async function onTotp(popup: Page, account: GitHubAccount, progress: Progress) { + const step = Math.floor(Date.now() / 30_000) + if (progress.totpStep === step || !(await popup.locator('#app_totp').count())) return + await popup.fill('#app_totp', totp(account.totpSecret)) + // GitHub submits the code on its own once six digits are in. + await popup + .locator('button[type=submit]') + .click({ timeout: 2_000 }) + .catch(() => {}) + progress.totpStep = step +} + +async function onAuthorize(popup: Page, text: string) { + const authorize = popup.locator("button[name='authorize'][value='1']") + if (await authorize.count()) await authorize.click() + else if (text.includes('redirect_uri') || text.includes('be careful')) + throw new Error( + "GitHub refused the authorization request: the app's callback URL does not match", + ) +} + +/** Act on one GitHub page. */ +async function onGitHubPage( + popup: Page, + account: GitHubAccount, + progress: Progress, + path: string, + text: string, +) { + if ( + text.includes("couldn't respond to your request in time") || + text.includes('something went wrong') + ) + return onErrorPage(popup, progress) + if (path === '/login' || path === '/session') return onLogin(popup, account, progress, text) + if (path === '/sessions/two-factor/app') return onTotp(popup, account, progress) + if (path.startsWith('/sessions/two-factor')) { + await popup.goto('https://github.com/sessions/two-factor/app') + return + } + if (path === '/login/oauth/authorize') return onAuthorize(popup, text) + if (path.startsWith('/sessions/verified-device')) + throw new Error('GitHub asked for device verification: the account has no TOTP method') +} + /** * Sign in and authorize on GitHub in `popup`, until it leaves github.com. A * port of libid-server-rs' ceremony-tests/src/browser/github.rs: fill the login @@ -30,69 +113,21 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) */ export async function authorizeOnGitHub(popup: Page, account: GitHubAccount, budgetMs = 180_000) { const started = Date.now() - let filled = false - let totpStep: number | undefined - let rateLimited = 0 - let errored = 0 - + const progress: Progress = { filled: false, rateLimited: 0, errored: 0 } while (Date.now() - started < budgetMs) { if (popup.isClosed()) return - let url: URL - try { - url = new URL(popup.url()) - } catch { - await sleep(250) - continue - } - if (url.hostname !== 'github.com') { - // Before GitHub (Prefetch) or after it (Callback, Prover): not ours to drive. - if (filled || url.pathname.startsWith('/auth/callback')) return - await sleep(250) - continue - } - const text = ((await popup.locator('body').textContent().catch(() => '')) ?? '').toLowerCase() - if (text.includes("couldn't respond to your request in time") || text.includes('something went wrong')) { - errored += 1 - if (errored > 3) throw new Error('GitHub answered its error page three times') - await sleep(10_000 * errored) - await popup.goBack().catch(() => {}) - filled = false - totpStep = undefined - continue - } - const path = url.pathname - if (path === '/login' || path === '/session') { - if (text.includes('too many requests') || text.includes('rate limit')) { - rateLimited += 1 - if (rateLimited > 3) throw new Error('GitHub rate-limited the login three times') - await sleep(30_000 * rateLimited) - await popup.reload() - filled = false - continue - } - if (!filled && (await popup.locator('#login_field').count())) { - await popup.fill('#login_field', account.username) - await popup.fill('#password', account.password) - await popup.click('input[type=submit][name=commit]') - filled = true - } - } else if (path === '/sessions/two-factor/app') { - const step = Math.floor(Date.now() / 30_000) - if (totpStep !== step && (await popup.locator('#app_totp').count())) { - await popup.fill('#app_totp', totp(account.totpSecret)) - // GitHub submits the code on its own once six digits are in. - await popup.locator('button[type=submit]').click({ timeout: 2_000 }).catch(() => {}) - totpStep = step - } - } else if (path.startsWith('/sessions/two-factor')) { - await popup.goto('https://github.com/sessions/two-factor/app') - } else if (path === '/login/oauth/authorize') { - const authorize = popup.locator("button[name='authorize'][value='1']") - if (await authorize.count()) await authorize.click() - else if (text.includes('redirect_uri') || text.includes('be careful')) - throw new Error("GitHub refused the authorization request: the app's callback URL does not match") - } else if (path.startsWith('/sessions/verified-device')) { - throw new Error('GitHub asked for device verification: the account has no TOTP method') + const url = URL.parse(popup.url()) + if (url?.hostname === 'github.com') { + const text = ( + (await popup + .locator('body') + .textContent() + .catch(() => '')) ?? '' + ).toLowerCase() + await onGitHubPage(popup, account, progress, url.pathname, text) + } else if (url && progress.filled) { + // Back from GitHub: Callback and Prover take over. + return } await sleep(250) } diff --git a/ts/apps/bind/playwright.live.config.ts b/ts/apps/bind/playwright.live.config.ts index ebdae983..c271ff7d 100644 --- a/ts/apps/bind/playwright.live.config.ts +++ b/ts/apps/bind/playwright.live.config.ts @@ -7,7 +7,8 @@ const envFile = process.env.LIBID_TEST_ENV_FILE if (envFile) { for (const line of readFileSync(envFile, 'utf8').split('\n')) { const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line) - if (match && process.env[match[1]!] === undefined) process.env[match[1]!] = match[2]!.replace(/^(['"])(.*)\1$/, '$2') + if (match && process.env[match[1]!] === undefined) + process.env[match[1]!] = match[2]!.replace(/^(['"])(.*)\1$/, '$2') } } diff --git a/ts/apps/bind/src/app.ts b/ts/apps/bind/src/app.ts index 1f9e277b..24227749 100644 --- a/ts/apps/bind/src/app.ts +++ b/ts/apps/bind/src/app.ts @@ -1,4 +1,9 @@ -import { type Ceremony, CeremonyStage, createCCDPClient, type IdentityResult } from '@libid/ceremony' +import { + type Ceremony, + CeremonyStage, + createCCDPClient, + type IdentityResult, +} from '@libid/ceremony' import { identityRegistryAbi, platformId, resolveId } from '@libid/contracts' import { PopupWindow } from '@libid/popup' import { @@ -61,7 +66,11 @@ async function connect() { show('holder', holder) } -async function submit(result: Extract, { status: 'accepted' }>, transactionData: Uint8Array, operationDomain: Uint8Array) { +async function submit( + result: Extract, { status: 'accepted' }>, + transactionData: Uint8Array, + operationDomain: Uint8Array, +) { if (!wallet || !holder) throw new Error('Wallet disconnected') show('proof-received', `${result.identity.userName} (${result.identity.userId})`) const github = platformId('github') @@ -115,7 +124,11 @@ async function initialize() { return } const operationDomain = hexToBytes( - await publicClient.readContract({ address: REGISTRY, abi: identityRegistryAbi, functionName: 'OPERATION_DOMAIN' }), + await publicClient.readContract({ + address: REGISTRY, + abi: identityRegistryAbi, + functionName: 'OPERATION_DOMAIN', + }), ) connectButton.addEventListener('click', () => { connect().then( @@ -123,7 +136,8 @@ async function initialize() { bindAnchor.setAttribute('aria-disabled', 'false') status('Ready. Click Bind GitHub.') }, - (error: unknown) => status(error instanceof Error ? error.message : 'Could not connect a wallet.'), + (error: unknown) => + status(error instanceof Error ? error.message : 'Could not connect a wallet.'), ) }) bindAnchor.addEventListener('click', (event) => { @@ -150,7 +164,10 @@ async function initialize() { bindAnchor.href = ceremony.launchUrl if (popup.opened) event.preventDefault() const off = ceremony.onStage((update) => { - show('stage', update.status === 'active' ? CeremonyStage.message(update.stage, 'GitHub') : update.status) + show( + 'stage', + update.status === 'active' ? CeremonyStage.message(update.stage, 'GitHub') : update.status, + ) }) void ceremony .proveUserIdentity() @@ -162,10 +179,14 @@ async function initialize() { } await submit(result, transactionData, operationDomain) }) - .catch((error: unknown) => status(error instanceof Error ? error.message : 'The binding failed.')) + .catch((error: unknown) => + status(error instanceof Error ? error.message : 'The binding failed.'), + ) .finally(off) }) status('Connect a wallet to start.') } -void initialize().catch(() => status('Could not load the Bridge configuration. Is the stack running?')) +void initialize().catch(() => + status('Could not load the Bridge configuration. Is the stack running?'), +) diff --git a/ts/apps/bind/src/index.html b/ts/apps/bind/src/index.html index 18267da6..89357f66 100644 --- a/ts/apps/bind/src/index.html +++ b/ts/apps/bind/src/index.html @@ -23,7 +23,7 @@

Bind an identity

Prove a GitHub account in the browser, then bind it on the local chain.

- Bind GitHub + Bind GitHub

diff --git a/ts/apps/bind/stack.ts b/ts/apps/bind/stack.ts index adcf3f47..cf0223c6 100644 --- a/ts/apps/bind/stack.ts +++ b/ts/apps/bind/stack.ts @@ -60,7 +60,8 @@ async function deployTool(): Promise { const url = `https://github.com/libid-org/chain-configurations/releases/download/v${DEPLOY.version}/${DEPLOY.asset}.tar.gz` const archive = Buffer.from(await (await fetch(url)).arrayBuffer()) const digest = createHash('sha256').update(archive).digest('hex') - if (digest !== DEPLOY.sha256) throw new Error(`libid-deploy archive digest ${digest} is not the pinned one`) + if (digest !== DEPLOY.sha256) + throw new Error(`libid-deploy archive digest ${digest} is not the pinned one`) const path = join(cache, `${DEPLOY.asset}.tar.gz`) writeFileSync(path, archive) execFileSync('tar', ['xzf', path, '-C', cache]) @@ -72,7 +73,16 @@ async function deploy() { const tool = await deployTool() execFileSync( tool, - ['apply', '--network', join(root, 'local-dev.toml'), '--rpc-url', RPC_URL, '--yes', '--confirm-fresh-deploy', '--dev'], + [ + 'apply', + '--network', + join(root, 'local-dev.toml'), + '--rpc-url', + RPC_URL, + '--yes', + '--confirm-fresh-deploy', + '--dev', + ], { stdio: 'inherit' }, ) const chain = createPublicClient({ transport: http(RPC_URL) }) @@ -83,7 +93,8 @@ async function deploy() { functionName: 'quoteBind', args: [platformId('github'), 1], }) - if (quote !== 2_000_000_000_000_000n) throw new Error(`quoteBind(github, 1) is ${quote}, expected 2e15`) + if (quote !== 2_000_000_000_000_000n) + throw new Error(`quoteBind(github, 1) is ${quote}, expected 2e15`) const code = (await rpc('eth_getCode', [REGISTRY, 'latest'])) as string if (code === '0x') throw new Error('IdentityRegistry has no code after deploy') } diff --git a/ts/apps/bind/vite.config.ts b/ts/apps/bind/vite.config.ts index 52d9b93e..f5c6101f 100644 --- a/ts/apps/bind/vite.config.ts +++ b/ts/apps/bind/vite.config.ts @@ -13,7 +13,12 @@ export default defineConfig({ port: 4695, strictPort: true, // The indexer's read API, same-origin so the page needs no CORS from it. - proxy: { '/indexer': { target: 'http://127.0.0.1:4689', rewrite: (path) => path.replace(/^\/indexer/, '') } }, + proxy: { + '/indexer': { + target: 'http://127.0.0.1:4689', + rewrite: (path) => path.replace(/^\/indexer/, ''), + }, + }, }, build: { outDir: join(directory, 'app'), emptyOutDir: true }, }) diff --git a/ts/biome.json b/ts/biome.json index 76ad3a9f..95961c05 100644 --- a/ts/biome.json +++ b/ts/biome.json @@ -13,7 +13,8 @@ "!**/test-results", "!**/playwright-report", "!**/coverage", - "!apps/dev/.cache" + "!apps/dev/.cache", + "!apps/bind/.cache" ] }, "formatter": { From 717f9f0a4c2b88002cc43ae0c1cad6ad77bc6603 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 2 Oct 2026 12:01:51 +0300 Subject: [PATCH 04/24] ci(bind): run the live GitHub binding nightly and on demand pnpm -C ts/apps/bind test:live now starts the stack and app through Playwright's webServer, runs the test and stops them; the stack saves its container logs when LIBID_STACK_LOGS names a file. The Live end-to-end workflow runs it with the GitHub test account from repository secrets, fails rather than skips when they are missing, and keeps the trace, video and logs of a failed run. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 56 ++++++++++++++++++++++++++ ts/apps/bind/e2e/github.ts | 11 ++++- ts/apps/bind/playwright.live.config.ts | 12 +++++- ts/apps/bind/stack.ts | 12 ++++++ 4 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/live-e2e.yml diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml new file mode 100644 index 00000000..2b1619d9 --- /dev/null +++ b/.github/workflows/live-e2e.yml @@ -0,0 +1,56 @@ +name: Live end-to-end + +# Binds a real platform identity from the browser UI to a mined transaction: +# real OAuth sign-in, real notary, in-browser proving, the real libID contracts +# on a local anvil, and the indexer. Not a required check: it depends on the +# platforms, their test accounts and the network. +# +# Every third-party action is pinned by commit SHA, with the tag in a comment. + +on: + workflow_dispatch: + schedule: + - cron: "23 3 * * *" + +# One run at a time: runs share the platforms' test accounts. +concurrency: + group: live-e2e + cancel-in-progress: false + +permissions: + contents: read + +jobs: + github: + name: GitHub binding (live) + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + # A missing secret fails the run instead of skipping the test. + LIBID_REQUIRE_LIVE: "1" + LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: ./.github/actions/setup-ts + + - name: Install Chromium + run: pnpm -C ts --filter @libid/bind exec playwright install --with-deps chromium + + - name: Bind a GitHub identity end to end + env: + GH_TEST_ALICE_USERNAME: ${{ secrets.GH_TEST_ALICE_USERNAME }} + GH_TEST_ALICE_PASSWORD: ${{ secrets.GH_TEST_ALICE_PASSWORD }} + GH_TEST_ALICE_TOTP_SECRET: ${{ secrets.GH_TEST_ALICE_TOTP_SECRET }} + run: pnpm -C ts/apps/bind test:live + + - name: Keep the trace, video and container logs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: live-e2e-github + path: | + ts/apps/bind/test-results + ${{ runner.temp }}/stack.log + retention-days: 14 + if-no-files-found: ignore diff --git a/ts/apps/bind/e2e/github.ts b/ts/apps/bind/e2e/github.ts index c4234ce0..ac14cb93 100644 --- a/ts/apps/bind/e2e/github.ts +++ b/ts/apps/bind/e2e/github.ts @@ -7,12 +7,19 @@ export interface GitHubAccount { totpSecret: string } -/** The test account, from GH_TEST_ALICE_*; undefined when any is missing. */ +/** + * The test account, from GH_TEST_ALICE_*; undefined when any is missing, + * which skips the test locally. With LIBID_REQUIRE_LIVE set, as CI sets it, a + * missing secret fails instead. + */ export function githubAccount(): GitHubAccount | undefined { const username = process.env.GH_TEST_ALICE_USERNAME const password = process.env.GH_TEST_ALICE_PASSWORD const totpSecret = process.env.GH_TEST_ALICE_TOTP_SECRET - return username && password && totpSecret ? { username, password, totpSecret } : undefined + if (username && password && totpSecret) return { username, password, totpSecret } + if (process.env.LIBID_REQUIRE_LIVE) + throw new Error('LIBID_REQUIRE_LIVE is set but GH_TEST_ALICE_* are missing') + return undefined } function totp(secret: string): string { diff --git a/ts/apps/bind/playwright.live.config.ts b/ts/apps/bind/playwright.live.config.ts index c271ff7d..38b96efb 100644 --- a/ts/apps/bind/playwright.live.config.ts +++ b/ts/apps/bind/playwright.live.config.ts @@ -12,13 +12,23 @@ if (envFile) { } } -/** Live: real GitHub, real notary, real chain. Needs `pnpm dev` running. */ +/** Live: real GitHub, real notary, real chain. */ export default defineConfig({ testDir: 'e2e', testMatch: '*.live.spec.ts', workers: 1, timeout: 10 * 60_000, reporter: [['list']], + // One command: start the stack and the app, test, then stop both. A stack + // already running locally is reused. + webServer: { + command: 'pnpm dev', + url: 'http://localhost:4695', + reuseExistingServer: !process.env.CI, + timeout: 15 * 60_000, + gracefulShutdown: { signal: 'SIGTERM', timeout: 120_000 }, + stdout: 'pipe', + }, use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:4695', diff --git a/ts/apps/bind/stack.ts b/ts/apps/bind/stack.ts index cf0223c6..ce3427ad 100644 --- a/ts/apps/bind/stack.ts +++ b/ts/apps/bind/stack.ts @@ -138,6 +138,18 @@ function stop(code: number) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error } } + // LIBID_STACK_LOGS keeps every container's log, for a failed CI run to upload. + const logs = process.env.LIBID_STACK_LOGS + if (logs) { + try { + writeFileSync( + logs, + execFileSync('docker', [...composeArgs, 'logs', '--no-color'], { maxBuffer: 1 << 28 }), + ) + } catch { + console.error('Could not save the container logs.') + } + } const down = spawn('docker', [...composeArgs, 'down', '--volumes'], { stdio: 'inherit' }) down.on('close', (code) => { if (code !== 0) process.exitCode = 1 From 31f3b8f3447f58889e219cc2442da717da9b71f1 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 2 Oct 2026 12:06:27 +0300 Subject: [PATCH 05/24] test(bind): bind a real X identity end to end The app binds X as it binds GitHub: both are TLSNotary ceremonies with the same payload. The X test restores the saved session libid-server-rs' ceremony export writes, presents the browser as a person's Chrome with the stealth script and client hints ported from that suite, and approves the OAuth app. The live workflow passes the X secrets. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 11 +- ts/apps/bind/e2e/bind.live.spec.ts | 55 +++-- ts/apps/bind/e2e/person.ts | 47 +++++ ts/apps/bind/e2e/stealth.js | 266 +++++++++++++++++++++++++ ts/apps/bind/e2e/x.ts | 153 ++++++++++++++ ts/apps/bind/playwright.live.config.ts | 6 +- ts/apps/bind/src/app.ts | 144 +++++++------ ts/apps/bind/src/index.html | 3 +- ts/biome.json | 3 +- 9 files changed, 609 insertions(+), 79 deletions(-) create mode 100644 ts/apps/bind/e2e/person.ts create mode 100644 ts/apps/bind/e2e/stealth.js create mode 100644 ts/apps/bind/e2e/x.ts diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 2b1619d9..b61f5df7 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -21,8 +21,8 @@ permissions: contents: read jobs: - github: - name: GitHub binding (live) + bind: + name: GitHub and X bindings (live) runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -37,18 +37,21 @@ jobs: - name: Install Chromium run: pnpm -C ts --filter @libid/bind exec playwright install --with-deps chromium - - name: Bind a GitHub identity end to end + - name: Bind GitHub and X identities end to end env: GH_TEST_ALICE_USERNAME: ${{ secrets.GH_TEST_ALICE_USERNAME }} GH_TEST_ALICE_PASSWORD: ${{ secrets.GH_TEST_ALICE_PASSWORD }} GH_TEST_ALICE_TOTP_SECRET: ${{ secrets.GH_TEST_ALICE_TOTP_SECRET }} + X_TEST_ALICE_USERNAME: ${{ secrets.X_TEST_ALICE_USERNAME }} + # The saved X session, base64 of libid-server-rs' `ceremony export x`. + X_TEST_ALICE_COOKIES: ${{ secrets.X_TEST_ALICE_COOKIES }} run: pnpm -C ts/apps/bind test:live - name: Keep the trace, video and container logs if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: live-e2e-github + name: live-e2e path: | ts/apps/bind/test-results ${{ runner.temp }}/stack.log diff --git a/ts/apps/bind/e2e/bind.live.spec.ts b/ts/apps/bind/e2e/bind.live.spec.ts index 39978f37..49b599a5 100644 --- a/ts/apps/bind/e2e/bind.live.spec.ts +++ b/ts/apps/bind/e2e/bind.live.spec.ts @@ -1,33 +1,40 @@ -import { expect, test } from '@playwright/test' +import { type BrowserContext, expect, type Page, test } from '@playwright/test' import { authorizeOnGitHub, githubAccount } from './github.ts' +import { presentAsPerson } from './person.ts' import { injectWallet } from './wallet.ts' +import { authorizeOnX, restoreXSession, xSession } from './x.ts' const RPC_URL = 'http://127.0.0.1:4688' /** anvil account #2: unlocked by anvil, funded, and not one the deploy uses. */ const HOLDER = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' -test('a GitHub identity is proved in the browser, bound on chain, and shown', async ({ - page, - context, -}) => { - const account = githubAccount() - test.skip(!account, 'GH_TEST_ALICE_USERNAME, _PASSWORD and _TOTP_SECRET are not set') - +/** + * Connect the wallet, start the platform's ceremony from its link, let + * `authorize` drive the popup through the platform, then check every step the + * page reports: the proved account, the transaction, its receipt, and the + * holder the registry and the indexer return. + */ +async function bindThroughTheUi( + page: Page, + context: BrowserContext, + platform: 'GitHub' | 'X', + expectedUser: string, + authorize: (popup: Page) => Promise, +) { await injectWallet(context, RPC_URL, HOLDER) await page.goto('/') await expect(page.getByTestId('status')).toHaveText('Connect a wallet to start.') - await page.getByRole('button', { name: 'Connect wallet' }).click() await expect(page.getByTestId('holder')).toHaveText(HOLDER) const [popup] = await Promise.all([ context.waitForEvent('page'), - page.getByRole('link', { name: 'Bind GitHub' }).click(), + page.getByRole('link', { name: `Bind ${platform}` }).click(), ]) - await authorizeOnGitHub(popup, account!) + await authorize(popup) // Proving runs in the popup's Prover document after the OAuth return. - await expect(page.getByTestId('proof-received')).toContainText(account!.username, { + await expect(page.getByTestId('proof-received')).toContainText(expectedUser, { ignoreCase: true, timeout: 300_000, }) @@ -39,4 +46,28 @@ test('a GitHub identity is proved in the browser, bound on chain, and shown', as await expect(page.getByTestId('indexer-holder')).toHaveText(new RegExp(`^${HOLDER}$`, 'i'), { timeout: 90_000, }) +} + +test('a GitHub identity is proved in the browser, bound on chain, and shown', async ({ + page, + context, +}) => { + const account = githubAccount() + test.skip(!account, 'GH_TEST_ALICE_USERNAME, _PASSWORD and _TOTP_SECRET are not set') + await bindThroughTheUi(page, context, 'GitHub', account!.username, (popup) => + authorizeOnGitHub(popup, account!), + ) +}) + +test('an X identity is proved in the browser, bound on chain, and shown', async ({ + page, + context, +}) => { + const session = xSession() + test.skip(!session, 'X_TEST_ALICE_COOKIES or X_TEST_ALICE_COOKIES_FILE is not set') + const username = process.env.X_TEST_ALICE_USERNAME + test.skip(!username, 'X_TEST_ALICE_USERNAME is not set') + await presentAsPerson(context) + await restoreXSession(context, session!) + await bindThroughTheUi(page, context, 'X', username!, (popup) => authorizeOnX(popup)) }) diff --git a/ts/apps/bind/e2e/person.ts b/ts/apps/bind/e2e/person.ts new file mode 100644 index 00000000..656386c1 --- /dev/null +++ b/ts/apps/bind/e2e/person.ts @@ -0,0 +1,47 @@ +import { fileURLToPath } from 'node:url' +import type { BrowserContext, Page } from '@playwright/test' + +/** The Chrome these tests present as: a person's, on an Intel Mac. */ +export const USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36' + +const brand = (name: string, version: string) => ({ brand: name, version }) + +/** Client hints matching USER_AGENT, which Chrome reports in navigator.userAgentData and sec-ch-ua. */ +const userAgentOverride = { + userAgent: USER_AGENT, + acceptLanguage: 'en-US,en;q=0.9', + platform: 'macOS', + userAgentMetadata: { + brands: [brand('Chromium', '145'), brand('Not:A-Brand', '99'), brand('Google Chrome', '145')], + fullVersionList: [ + brand('Chromium', '145.0.0.0'), + brand('Not:A-Brand', '99.0.0.0'), + brand('Google Chrome', '145.0.0.0'), + ], + platform: 'macOS', + platformVersion: '15.3.0', + architecture: 'x86', + model: '', + mobile: false, + bitness: '64', + wow64: false, + }, +} + +async function presentPage(context: BrowserContext, page: Page) { + const cdp = await context.newCDPSession(page) + await cdp.send('Emulation.setUserAgentOverride', userAgentOverride) +} + +/** + * Make every page of `context` read as a person's Chrome: the user agent and + * its client hints, and the stealth script before any page script. X and + * Google challenge automation they can see. Ported from libid-server-rs' + * ceremony-tests/src/browser/person.rs. + */ +export async function presentAsPerson(context: BrowserContext) { + await context.addInitScript({ path: fileURLToPath(new URL('./stealth.js', import.meta.url)) }) + for (const page of context.pages()) await presentPage(context, page) + context.on('page', (page) => void presentPage(context, page).catch(() => {})) +} diff --git a/ts/apps/bind/e2e/stealth.js b/ts/apps/bind/e2e/stealth.js new file mode 100644 index 00000000..86e49ed5 --- /dev/null +++ b/ts/apps/bind/e2e/stealth.js @@ -0,0 +1,266 @@ +// Ported from libid-server-rs ceremony-tests/src/browser/stealth.js. +// Runs in every new document before the page's own scripts: what the page +// can read is a person's Chrome on a Mac. + +// `navigator.webdriver` is `undefined`, as in a Chrome nobody automates. +Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true }) +Object.defineProperty(Navigator.prototype, 'webdriver', { + get: () => undefined, + configurable: true, +}) +Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }) +Object.defineProperty(navigator, 'vendor', { get: () => 'Google Inc.' }) +Object.defineProperty(navigator, 'platform', { get: () => 'MacIntel' }) + +// The client hints are the user agent metadata `person::prepare` sets over +// CDP, which Chrome itself reports through `navigator.userAgentData` and the +// `sec-ch-ua` headers. + +// `window.chrome`, with the members a page reads off it. +window.chrome = { + app: { + isInstalled: false, + InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, + RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' }, + getDetails: function () { + return null + }, + getIsInstalled: function () { + return false + }, + runningState: function () { + return 'cannot_run' + }, + }, + runtime: { + OnInstalledReason: { + CHROME_UPDATE: 'chrome_update', + INSTALL: 'install', + SHARED_MODULE_UPDATE: 'shared_module_update', + UPDATE: 'update', + }, + OnRestartRequiredReason: { + APP_UPDATE: 'app_update', + OS_UPDATE: 'os_update', + PERIODIC: 'periodic', + }, + PlatformArch: { + ARM: 'arm', + ARM64: 'arm64', + MIPS: 'mips', + MIPS64: 'mips64', + X86_32: 'x86-32', + X86_64: 'x86-64', + }, + PlatformNaclArch: { + ARM: 'arm', + MIPS: 'mips', + MIPS64: 'mips64', + X86_32: 'x86-32', + X86_64: 'x86-64', + }, + PlatformOs: { + ANDROID: 'android', + CROS: 'cros', + FUCHSIA: 'fuchsia', + LINUX: 'linux', + MAC: 'mac', + OPENBSD: 'openbsd', + WIN: 'win', + }, + RequestUpdateCheckStatus: { + NO_UPDATE: 'no_update', + THROTTLED: 'throttled', + UPDATE_AVAILABLE: 'update_available', + }, + connect: function () {}, + sendMessage: function () {}, + }, + csi: function () { + return {} + }, + loadTimes: function () { + return {} + }, +} + +// The PDF viewer plugins a desktop Chrome lists. +Object.defineProperty(navigator, 'plugins', { + get: () => { + const plugin = (name, filename, description) => { + const p = { name, filename, description, length: 1 } + p[0] = { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' } + return p + } + const plugins = [ + plugin('PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'), + plugin('Chrome PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'), + plugin('Chromium PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'), + plugin('Microsoft Edge PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'), + plugin('WebKit built-in PDF', 'internal-pdf-viewer', 'Portable Document Format'), + ] + plugins.item = (i) => plugins[i] || null + plugins.namedItem = (n) => plugins.find((p) => p.name === n) || null + plugins.refresh = () => {} + return plugins + }, +}) +Object.defineProperty(navigator, 'mimeTypes', { + get: () => { + const types = [ + { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' }, + ] + types.item = (i) => types[i] || null + types.namedItem = (n) => types.find((m) => m.type === n) || null + return types + }, +}) + +// The notifications permission answers as `Notification.permission` does. +const originalQuery = window.navigator.permissions.query.bind(window.navigator.permissions) +window.navigator.permissions.query = (parameters) => + parameters.name === 'notifications' + ? Promise.resolve({ state: Notification.permission }) + : originalQuery(parameters) + +// WebGL names an Intel GPU. +const getParameter = WebGLRenderingContext.prototype.getParameter +WebGLRenderingContext.prototype.getParameter = function (parameter) { + if (parameter === 37445) return 'Intel Inc.' + if (parameter === 37446) return 'Intel Iris OpenGL Engine' + return getParameter.call(this, parameter) +} + +// A 1920x1080 screen with a 1440x900 window on it. +Object.defineProperty(screen, 'width', { get: () => 1920 }) +Object.defineProperty(screen, 'height', { get: () => 1080 }) +Object.defineProperty(screen, 'availWidth', { get: () => 1920 }) +Object.defineProperty(screen, 'availHeight', { get: () => 1080 }) +Object.defineProperty(screen, 'colorDepth', { get: () => 24 }) +Object.defineProperty(screen, 'pixelDepth', { get: () => 24 }) +Object.defineProperty(window, 'outerWidth', { get: () => 1440 }) +Object.defineProperty(window, 'outerHeight', { get: () => 900 }) +Object.defineProperty(window, 'innerWidth', { get: () => 1440 }) +Object.defineProperty(window, 'innerHeight', { get: () => 900 }) + +// A wired connection, eight cores, eight gigabytes. +Object.defineProperty(navigator, 'connection', { + get: () => ({ + effectiveType: '4g', + rtt: 50, + downlink: 10, + saveData: false, + }), +}) +Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 }) +Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 }) + +// The time zone of the locale. +const resolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions +Intl.DateTimeFormat.prototype.resolvedOptions = function () { + const options = resolvedOptions.call(this) + options.timeZone = 'America/New_York' + return options +} + +// Canvas and audio fingerprints differ from run to run by one step. +const toDataURL = HTMLCanvasElement.prototype.toDataURL +HTMLCanvasElement.prototype.toDataURL = function (type) { + try { + const context = this.getContext('2d') + if (context && this.width > 0 && this.height > 0) { + const image = context.getImageData(0, 0, this.width, this.height) + for (let i = 0; i < Math.min(image.data.length, 40); i += 4) { + image.data[i] = (image.data[i] + 1) & 0xff + } + context.putImageData(image, 0, 0) + } + } catch (e) {} + return toDataURL.call(this, type) +} +if (typeof AudioContext !== 'undefined') { + const getChannelData = AudioBuffer.prototype.getChannelData + AudioBuffer.prototype.getChannelData = function (channel) { + const data = getChannelData.call(this, channel) + if (data.length > 0) data[0] += 0.0000001 + return data + } +} + +// No automation globals on `window`. +for (const key of Object.keys(window)) { + if ( + key.startsWith('cdc_') || + key.startsWith('__playwright') || + key.startsWith('__selenium') || + key.startsWith('__webdriver') || + key.startsWith('__driver_') || + key === 'callPhantom' || + key === '_phantom' || + key === 'domAutomation' || + key === 'domAutomationController' + ) { + delete window[key] + } +} + +// Stack traces carry no devtools frames. +const prepareStackTrace = Error.prepareStackTrace +Error.prepareStackTrace = function (error, stack) { + const filtered = stack.filter((frame) => { + const fn = frame.getFunctionName() || '' + const file = frame.getFileName() || '' + return ( + !file.includes('__puppeteer') && + !file.includes('pptr:') && + !fn.includes('Runtime.evaluate') && + !file.includes('devtools') && + !file.includes('__chromium') && + !file.includes('chrome-extension://') && + !file.includes('//# sourceURL=') + ) + }) + if (prepareStackTrace) return prepareStackTrace(error, filtered) + return error + '\n' + filtered.map((f) => ' at ' + f).join('\n') +} + +// Child frames answer `webdriver` the same way. +const contentWindow = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow') +if (contentWindow) { + Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', { + get: function () { + const win = contentWindow.get.call(this) + if (!win) return win + try { + Object.defineProperty(win.navigator, 'webdriver', { + get: () => undefined, + configurable: true, + }) + } catch (e) {} + return win + }, + }) +} + +// The codecs a desktop Chrome plays. +const canPlayType = HTMLMediaElement.prototype.canPlayType +HTMLMediaElement.prototype.canPlayType = function (type) { + const result = canPlayType.call(this, type) + if (result === '') { + if (type === 'video/mp4; codecs="avc1.42E01E"') return 'probably' + if (type === 'video/mp4; codecs="avc1.4D401E"') return 'probably' + if (type === 'video/mp4; codecs="avc1.64001E"') return 'probably' + if (type === 'video/webm; codecs="vp8"') return 'probably' + if (type === 'video/webm; codecs="vp9"') return 'probably' + if (type === 'audio/mpeg') return 'probably' + if (type === 'audio/ogg; codecs="vorbis"') return 'probably' + if (type === 'audio/mp4; codecs="mp4a.40.2"') return 'probably' + } + return result +} + +// `performance.now()` carries sub-millisecond noise. +const now = performance.now.bind(performance) +performance.now = function () { + return now() + Math.random() * 0.1 +} diff --git a/ts/apps/bind/e2e/x.ts b/ts/apps/bind/e2e/x.ts new file mode 100644 index 00000000..b8a2c52e --- /dev/null +++ b/ts/apps/bind/e2e/x.ts @@ -0,0 +1,153 @@ +import { readFileSync } from 'node:fs' +import { isAbsolute, join } from 'node:path' +import type { BrowserContext, Page } from '@playwright/test' + +/** One cookie as libid-server-rs' `ceremony export x` saves it. */ +interface Stored { + name: string + value: string + domain: string + path?: string + secure?: boolean | null + http_only?: boolean + same_site?: string | null + expires?: number +} + +/** + * The saved X session, from X_TEST_ALICE_COOKIES (base64 JSON) or + * X_TEST_ALICE_COOKIES_FILE (a path, relative to the env file's directory); + * undefined when neither is set. Renew it with `cargo run --bin ceremony -- + * export x` in libid-server-rs' ceremony-tests. + */ +export function xSession(): Stored[] | undefined { + const encoded = process.env.X_TEST_ALICE_COOKIES + const file = process.env.X_TEST_ALICE_COOKIES_FILE + let json: string + if (encoded) json = Buffer.from(encoded.trim(), 'base64').toString('utf8') + else if (file) + json = readFileSync( + isAbsolute(file) ? file : join(process.env.LIBID_TEST_ENV_DIR ?? '.', file), + 'utf8', + ) + else if (process.env.LIBID_REQUIRE_LIVE) + throw new Error('LIBID_REQUIRE_LIVE is set but no saved X session is') + else return undefined + const parsed = JSON.parse(json) as Stored[] | { cookies: Stored[] } + const cookies = Array.isArray(parsed) ? parsed : parsed.cookies + if (!cookies.some((cookie) => cookie.name === 'auth_token')) + throw new Error('The saved X session has no auth_token cookie') + return cookies +} + +const sameSite = (value: string | null | undefined): 'Strict' | 'Lax' | 'None' | undefined => { + const lower = value?.toLowerCase() + return lower === 'strict' + ? 'Strict' + : lower === 'lax' + ? 'Lax' + : lower === 'none' || lower === 'no_restriction' + ? 'None' + : undefined +} + +/** Put the saved session on both hosts X answers on, as the Rust suite does. */ +export async function restoreXSession(context: BrowserContext, cookies: Stored[]) { + await context.addCookies( + ['x.com', 'twitter.com'].flatMap((host) => + cookies.map((cookie) => ({ + name: cookie.name, + value: cookie.value, + domain: cookie.domain.replace('x.com', host), + path: cookie.path || '/', + secure: cookie.secure ?? true, + httpOnly: cookie.http_only ?? false, + sameSite: sameSite(cookie.same_site), + expires: cookie.expires && cookie.expires > 0 ? cookie.expires : -1, + })), + ), + ) +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +const CHALLENGE = [ + 'performing security verification', + 'verify you are human', + 'verifies you are not a bot', + 'checking your browser', +] + +/** X's cookie banner accepted, if it shows. */ +const ACCEPT_COOKIES = `(() => { + const span = [...document.querySelectorAll('span')].find(s => s.textContent.includes('Accept all cookies')); + const button = span && span.closest('button,[role=button],div[role=button]'); + if (button) { button.click(); return true; } + return false; +})()` + +/** The visible, enabled consent control marked for a real click; X serves more than one consent page. */ +const MARK_CONSENT = `(() => { + document.querySelectorAll('[data-libid-consent]').forEach(e => e.removeAttribute('data-libid-consent')); + const button = [...document.querySelectorAll("[data-testid='OAuth_Consent_Button'],button,[role=button],input[type=submit],a")].find(e => + (e.getAttribute('data-testid') === 'OAuth_Consent_Button' || /^(authorize app)$/i.test((e.innerText || e.value || '').trim())) && + !e.disabled && e.getAttribute('aria-disabled') !== 'true' && e.getClientRects().length > 0); + if (button) { button.setAttribute('data-libid-consent', '1'); return true; } + return false; +})()` + +const onX = (url: URL | null) => + url?.hostname === 'x.com' || url?.hostname === 'twitter.com' || url?.hostname?.endsWith('.x.com') + +/** + * Approve the OAuth app on X in `popup`, signed in by the saved session, until + * it leaves X. A port of libid-server-rs' ceremony-tests/src/browser/x.rs + * without its interactive sign-in: a login page means the saved session has + * expired. + */ +export async function authorizeOnX(popup: Page, budgetMs = 180_000) { + const started = Date.now() + let visited = false + let consentedAt: number | undefined + let challengedAt: number | undefined + while (Date.now() - started < budgetMs) { + if (popup.isClosed()) return + const url = URL.parse(popup.url()) + if (!onX(url)) { + if (visited) return + await sleep(250) + continue + } + visited = true + const text = ( + (await popup + .locator('body') + .textContent() + .catch(() => '')) ?? '' + ).toLowerCase() + if (CHALLENGE.some((marker) => text.includes(marker))) { + challengedAt ??= Date.now() + if (Date.now() - challengedAt > 90_000) + throw new Error('X security verification did not clear within 90 s') + } else if (url!.pathname.startsWith('/i/flow/login') || url!.pathname === '/login') { + throw new Error( + 'X asked to sign in: the saved session has expired. Renew it with `ceremony export x`.', + ) + } else if (url!.pathname.startsWith('/i/oauth2/authorize')) { + challengedAt = undefined + await popup.evaluate(ACCEPT_COOKIES).catch(() => {}) + if ( + (consentedAt === undefined || Date.now() - consentedAt > 10_000) && + (await popup.evaluate(MARK_CONSENT).catch(() => false)) + ) { + await popup + .locator('[data-libid-consent="1"]') + .click({ timeout: 5_000 }) + .catch(() => {}) + consentedAt = Date.now() + } + } + await sleep(250) + } + throw new Error(`No X authorization in ${budgetMs / 1000} s; stopped on ${popup.url()}`) +} diff --git a/ts/apps/bind/playwright.live.config.ts b/ts/apps/bind/playwright.live.config.ts index 38b96efb..5e02f6ae 100644 --- a/ts/apps/bind/playwright.live.config.ts +++ b/ts/apps/bind/playwright.live.config.ts @@ -1,10 +1,12 @@ import { readFileSync } from 'node:fs' +import { dirname } from 'node:path' import { defineConfig, devices } from '@playwright/test' // Test secrets from a dotenv file when LIBID_TEST_ENV_FILE names one; values // already in the environment win. Nothing is printed. const envFile = process.env.LIBID_TEST_ENV_FILE if (envFile) { + process.env.LIBID_TEST_ENV_DIR ??= dirname(envFile) for (const line of readFileSync(envFile, 'utf8').split('\n')) { const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line) if (match && process.env[match[1]!] === undefined) @@ -12,7 +14,7 @@ if (envFile) { } } -/** Live: real GitHub, real notary, real chain. */ +/** Live: real platforms, real notary, real chain. */ export default defineConfig({ testDir: 'e2e', testMatch: '*.live.spec.ts', @@ -33,6 +35,8 @@ export default defineConfig({ ...devices['Desktop Chrome'], baseURL: 'http://localhost:4695', headless: !process.env.HEADED, + // Automation X and Google can see makes them challenge; see e2e/person.ts. + launchOptions: { args: ['--disable-blink-features=AutomationControlled'] }, trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure', diff --git a/ts/apps/bind/src/app.ts b/ts/apps/bind/src/app.ts index 24227749..7990d5fa 100644 --- a/ts/apps/bind/src/app.ts +++ b/ts/apps/bind/src/app.ts @@ -1,4 +1,5 @@ import { + type CCDPClient, type Ceremony, CeremonyStage, createCCDPClient, @@ -46,7 +47,16 @@ const show = (id: string, text: string) => { } const status = (text: string) => show('status', text) const connectButton = document.querySelector('#connect')! -const bindAnchor = document.querySelector('#bind-github')! + +/** The platforms whose proofs this app encodes: both are TLSNotary ceremonies. */ +type Platform = 'github' | 'x' +const names: Record = { github: 'GitHub', x: 'X' } +const anchors = Object.fromEntries( + (Object.keys(names) as Platform[]).map((platform) => [ + platform, + document.querySelector(`#bind-${platform}`)!, + ]), +) as Record let wallet: WalletClient | undefined let holder: Address | undefined @@ -67,18 +77,19 @@ async function connect() { } async function submit( - result: Extract, { status: 'accepted' }>, + platform: Platform, + result: Extract, { status: 'accepted' }>, transactionData: Uint8Array, operationDomain: Uint8Array, ) { if (!wallet || !holder) throw new Error('Wallet disconnected') show('proof-received', `${result.identity.userName} (${result.identity.userId})`) - const github = platformId('github') + const id = platformId(platform) const value = await publicClient.readContract({ address: REGISTRY, abi: identityRegistryAbi, functionName: 'quoteBind', - args: [github, result.oauthProof.platformCeremonyVersion], + args: [id, result.oauthProof.platformCeremonyVersion], }) const payload = encodeTlsNotaryPayload(result.oauthProof, operationDomain, transactionData) status('Submitting the binding…') @@ -87,7 +98,7 @@ async function submit( address: REGISTRY, abi: identityRegistryAbi, functionName: 'bind', - args: [github, result.oauthProof.platformCeremonyVersion, payload, true], + args: [id, result.oauthProof.platformCeremonyVersion, payload, true], value, }) const hash = await wallet.writeContract({ ...request, account: wallet.account ?? holder }) @@ -96,17 +107,17 @@ async function submit( show('receipt-status', `${receipt.status} in block ${receipt.blockNumber}`) if (receipt.status !== 'success') throw new Error('The binding transaction reverted') - const bound = await resolveId(registry, github, result.identity.userId) + const bound = await resolveId(registry, id, result.identity.userId) show('registry-holder', bound ?? 'not bound') status('Bound on chain. Waiting for the indexer…') - show('indexer-holder', await indexed(result.identity.userId)) + show('indexer-holder', await indexed(platform, result.identity.userId)) status('Done.') } -/** The holder the indexer reports for a GitHub id, polled until it appears. */ -async function indexed(userId: string): Promise { +/** The holder the indexer reports for an id, polled until it appears. */ +async function indexed(platform: Platform, userId: string): Promise { for (let attempt = 0; attempt < 60; attempt++) { - const response = await fetch(`/indexer/v1/resolve/id/github/${encodeURIComponent(userId)}`) + const response = await fetch(`/indexer/v1/resolve/id/${platform}/${encodeURIComponent(userId)}`) if (response.ok) { const body = (await response.json()) as { bindings: { chainId: number; owner: string }[] } const local = body.bindings.find((binding) => binding.chainId === chain.id) @@ -117,12 +128,65 @@ async function indexed(userId: string): Promise { return 'not indexed after 60 s' } -async function initialize() { - const client = await createCCDPClient({ oauthBridge: BRIDGE }) - if (!client.enabledPlatforms.includes('github')) { - status('The Bridge does not enable GitHub.') +/** Start a ceremony for `platform` from its anchor's click, then bind what it proves. */ +function launch( + client: CCDPClient, + platform: Platform, + event: MouseEvent, + operationDomain: Uint8Array, +) { + const anchor = anchors[platform] + if (!holder) { + event.preventDefault() + return + } + const id = crypto.randomUUID() + const target = `ceremony-${id}` + // Window creation stays synchronous with the click. + const popup = PopupWindow.open(target) + const connection = client.connect(popup, { connectionId: id }) + const transactionData = authorizedTransactionData(holder) + let ceremony: Ceremony + try { + ceremony = client.new(connection, id, platform, ledger, operationDomain, transactionData) + } catch (error) { + event.preventDefault() + connection.close() + status(error instanceof Error ? error.message : 'Unable to start the ceremony.') return } + anchor.target = target + anchor.href = ceremony.launchUrl + if (popup.opened) event.preventDefault() + const off = ceremony.onStage((update) => { + show( + 'stage', + update.status === 'active' + ? CeremonyStage.message(update.stage, names[platform]) + : update.status, + ) + }) + void ceremony + .proveUserIdentity() + .then(async (result) => { + connection.close() + if (result.status !== 'accepted') { + status(`${names[platform]} denied the authorization.`) + return + } + await submit(platform, result, transactionData, operationDomain) + }) + .catch((error: unknown) => + status(error instanceof Error ? error.message : 'The binding failed.'), + ) + .finally(off) +} + +async function initialize() { + const client = await createCCDPClient({ oauthBridge: BRIDGE }) + const enabled = (Object.keys(names) as Platform[]).filter((platform) => + client.enabledPlatforms.includes(platform), + ) const operationDomain = hexToBytes( await publicClient.readContract({ address: REGISTRY, @@ -133,57 +197,17 @@ async function initialize() { connectButton.addEventListener('click', () => { connect().then( () => { - bindAnchor.setAttribute('aria-disabled', 'false') - status('Ready. Click Bind GitHub.') + for (const platform of enabled) anchors[platform].setAttribute('aria-disabled', 'false') + status('Ready. Choose a platform to bind.') }, (error: unknown) => status(error instanceof Error ? error.message : 'Could not connect a wallet.'), ) }) - bindAnchor.addEventListener('click', (event) => { - if (!holder) { - event.preventDefault() - return - } - const id = crypto.randomUUID() - const target = `ceremony-${id}` - // Window creation stays synchronous with the click. - const popup = PopupWindow.open(target) - const connection = client.connect(popup, { connectionId: id }) - const transactionData = authorizedTransactionData(holder) - let ceremony: Ceremony<'github'> - try { - ceremony = client.new(connection, id, 'github', ledger, operationDomain, transactionData) - } catch (error) { - event.preventDefault() - connection.close() - status(error instanceof Error ? error.message : 'Unable to start the ceremony.') - return - } - bindAnchor.target = target - bindAnchor.href = ceremony.launchUrl - if (popup.opened) event.preventDefault() - const off = ceremony.onStage((update) => { - show( - 'stage', - update.status === 'active' ? CeremonyStage.message(update.stage, 'GitHub') : update.status, - ) - }) - void ceremony - .proveUserIdentity() - .then(async (result) => { - connection.close() - if (result.status !== 'accepted') { - status('GitHub denied the authorization.') - return - } - await submit(result, transactionData, operationDomain) - }) - .catch((error: unknown) => - status(error instanceof Error ? error.message : 'The binding failed.'), - ) - .finally(off) - }) + for (const platform of enabled) + anchors[platform].addEventListener('click', (event) => + launch(client, platform, event, operationDomain), + ) status('Connect a wallet to start.') } diff --git a/ts/apps/bind/src/index.html b/ts/apps/bind/src/index.html index 89357f66..f77f3b3f 100644 --- a/ts/apps/bind/src/index.html +++ b/ts/apps/bind/src/index.html @@ -20,10 +20,11 @@

Bind an identity

-

Prove a GitHub account in the browser, then bind it on the local chain.

+

Prove a GitHub or X account in the browser, then bind it on the local chain.

Bind GitHub + Bind X

diff --git a/ts/biome.json b/ts/biome.json index 95961c05..0deaffda 100644 --- a/ts/biome.json +++ b/ts/biome.json @@ -14,7 +14,8 @@ "!**/playwright-report", "!**/coverage", "!apps/dev/.cache", - "!apps/bind/.cache" + "!apps/bind/.cache", + "!apps/bind/e2e/stealth.js" ] }, "formatter": { From 5e75e12317557bcd62860cff71a0594f09b40fc9 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 2 Oct 2026 12:17:47 +0300 Subject: [PATCH 06/24] test(bind): bind a real Google identity end to end The stack runs the keeper once after the deploy, so GoogleJwtRoots trusts Google's current keys through a notarized MPC-TLS reading; the notary opens its wire port for it. The app encodes the Google verifier's payload, whose client identifier is the token audience the result carries on its identity. The test restores the saved Google session and consents in the popup; X and Google share one saved-session loader. The nightly workflow takes the Google secrets. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 12 +++-- ts/apps/bind/compose.yaml | 19 +++++++ ts/apps/bind/e2e/bind.live.spec.ts | 19 ++++++- ts/apps/bind/e2e/google.ts | 87 ++++++++++++++++++++++++++++++ ts/apps/bind/e2e/session.ts | 74 +++++++++++++++++++++++++ ts/apps/bind/e2e/x.ts | 69 +++--------------------- ts/apps/bind/keeper.toml | 10 ++++ ts/apps/bind/src/app.ts | 35 +++++++++--- ts/apps/bind/src/evm.ts | 42 +++++++++++++++ ts/apps/bind/src/index.html | 1 + ts/apps/bind/stack.ts | 35 +++++++++++- 11 files changed, 328 insertions(+), 75 deletions(-) create mode 100644 ts/apps/bind/e2e/google.ts create mode 100644 ts/apps/bind/e2e/session.ts create mode 100644 ts/apps/bind/keeper.toml diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index b61f5df7..696ca5a6 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -2,8 +2,9 @@ name: Live end-to-end # Binds a real platform identity from the browser UI to a mined transaction: # real OAuth sign-in, real notary, in-browser proving, the real libID contracts -# on a local anvil, and the indexer. Not a required check: it depends on the -# platforms, their test accounts and the network. +# on a local anvil with Google's keys rotated in by the keeper, and the +# indexer. Not a required check: it depends on the platforms, their test +# accounts and the network. # # Every third-party action is pinned by commit SHA, with the tag in a comment. @@ -22,7 +23,7 @@ permissions: jobs: bind: - name: GitHub and X bindings (live) + name: GitHub, X and Google bindings (live) runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -37,7 +38,7 @@ jobs: - name: Install Chromium run: pnpm -C ts --filter @libid/bind exec playwright install --with-deps chromium - - name: Bind GitHub and X identities end to end + - name: Bind GitHub, X and Google identities end to end env: GH_TEST_ALICE_USERNAME: ${{ secrets.GH_TEST_ALICE_USERNAME }} GH_TEST_ALICE_PASSWORD: ${{ secrets.GH_TEST_ALICE_PASSWORD }} @@ -45,6 +46,9 @@ jobs: X_TEST_ALICE_USERNAME: ${{ secrets.X_TEST_ALICE_USERNAME }} # The saved X session, base64 of libid-server-rs' `ceremony export x`. X_TEST_ALICE_COOKIES: ${{ secrets.X_TEST_ALICE_COOKIES }} + GOOGLE_TEST_ALICE_EMAIL: ${{ secrets.GOOGLE_TEST_ALICE_EMAIL }} + # The saved Google session, base64 of `ceremony export google`. + GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} run: pnpm -C ts/apps/bind test:live - name: Keep the trace, video and container logs diff --git a/ts/apps/bind/compose.yaml b/ts/apps/bind/compose.yaml index 9130c447..79663e9f 100644 --- a/ts/apps/bind/compose.yaml +++ b/ts/apps/bind/compose.yaml @@ -25,6 +25,8 @@ services: NOTARY_MAX_SESSIONS_PER_IP: "0" NOTARY_PER_IP_UPGRADES: "" NOTARY_PER_IP_BYTES: "" + # The MPC-TLS wire port, for the keeper's reading of Google's keys. + NOTARY_PORT: "7047" # anvil account #1, the signer chain-configurations' local-dev network # trusts. A public test key, never a production notary identity. SIGNING_KEY: "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" @@ -64,6 +66,21 @@ services: - .cache/ccdp/public:/home/sws/public:ro - .cache/ccdp/sws.toml:/etc/sws.toml:ro + keeper: + # keeper:0.3.1 by index digest. Not started by `up`: stack.ts runs it once + # after the deploy, so GoogleJwtRoots trusts Google's current keys. + image: ghcr.io/libid-org/keeper@sha256:6a97a1bf4d398ab2747155c4f8490c580bef522d83d5c4475ac8bad24daa546d + profiles: [keeper] + command: ["--config", "/keeper.toml", "once"] + configs: + - source: keeper + target: /keeper.toml + depends_on: + notary: + condition: service_healthy + anvil: + condition: service_healthy + postgres: # postgres:16 by index digest. No volume: every run starts empty. image: postgres@sha256:1a6ab3f5345eb6dbe04a1349529caabdb0ab09293a09590fad07b2246bfa4b54 @@ -112,3 +129,5 @@ services: configs: bridge: file: ./bridge-config.toml + keeper: + file: ./keeper.toml diff --git a/ts/apps/bind/e2e/bind.live.spec.ts b/ts/apps/bind/e2e/bind.live.spec.ts index 49b599a5..d5097f5b 100644 --- a/ts/apps/bind/e2e/bind.live.spec.ts +++ b/ts/apps/bind/e2e/bind.live.spec.ts @@ -1,6 +1,8 @@ import { type BrowserContext, expect, type Page, test } from '@playwright/test' import { authorizeOnGitHub, githubAccount } from './github.ts' +import { authorizeOnGoogle, googleSession } from './google.ts' import { presentAsPerson } from './person.ts' +import { restoreSession } from './session.ts' import { injectWallet } from './wallet.ts' import { authorizeOnX, restoreXSession, xSession } from './x.ts' @@ -17,7 +19,7 @@ const HOLDER = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' async function bindThroughTheUi( page: Page, context: BrowserContext, - platform: 'GitHub' | 'X', + platform: 'GitHub' | 'X' | 'Google', expectedUser: string, authorize: (popup: Page) => Promise, ) { @@ -71,3 +73,18 @@ test('an X identity is proved in the browser, bound on chain, and shown', async await restoreXSession(context, session!) await bindThroughTheUi(page, context, 'X', username!, (popup) => authorizeOnX(popup)) }) + +test('a Google identity is proved in the browser, bound on chain, and shown', async ({ + page, + context, +}) => { + const session = googleSession() + test.skip(!session, 'GOOGLE_TEST_ALICE_COOKIES or GOOGLE_TEST_ALICE_COOKIES_FILE is not set') + const email = process.env.GOOGLE_TEST_ALICE_EMAIL + test.skip(!email, 'GOOGLE_TEST_ALICE_EMAIL is not set') + await presentAsPerson(context) + await restoreSession(context, session!) + await bindThroughTheUi(page, context, 'Google', email!, (popup) => + authorizeOnGoogle(popup, email!), + ) +}) diff --git a/ts/apps/bind/e2e/google.ts b/ts/apps/bind/e2e/google.ts new file mode 100644 index 00000000..b54ed347 --- /dev/null +++ b/ts/apps/bind/e2e/google.ts @@ -0,0 +1,87 @@ +import type { Page } from '@playwright/test' +import { savedSession } from './session.ts' + +/** + * The saved Google session, from GOOGLE_TEST_ALICE_COOKIES or + * GOOGLE_TEST_ALICE_COOKIES_FILE. Renew it with `cargo run --bin ceremony -- + * export google` in libid-server-rs' ceremony-tests: Google refuses a sign-in + * in an automated browser, but honours a session made elsewhere. + */ +export const googleSession = () => + savedSession('GOOGLE_TEST_ALICE', ['SID', '__Secure-1PSID', '__Secure-3PSID']) + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** Pages that no automation gets past: the session or the OAuth client needs a person. */ +const REFUSED: [string, string][] = [ + ['this browser or app may not be secure', 'Google refused this browser'], + ["verify it's you", 'Google asks to verify the account interactively'], + ['verify you’re human', 'Google asks to verify the account interactively'], + ['redirect_uri_mismatch', 'Google rejected the redirect URI of the OAuth client'], + ['access blocked', 'Google blocked the OAuth client'], + ['has not completed the google verification process', 'Google blocked the OAuth client'], + ['invalid_client', 'Google does not know the OAuth client'], + ['unauthorized_client', 'Google refused the OAuth client'], + ['unsupported_response_type', 'Google refused the id_token response type'], +] + +const LOGIN_VISIBLE = `[...document.querySelectorAll('input[type=password],input[type=email],input[name=identifier]')] + .some(e => e.getClientRects().length && getComputedStyle(e).visibility !== 'hidden')` + +/** The account row for `email`, else a Continue or Allow button, marked for a real click. */ +const markNext = (email: string) => `(() => { + document.querySelectorAll('[data-libid-google]').forEach(e => e.removeAttribute('data-libid-google')); + const usable = e => !e.disabled && e.getAttribute('aria-disabled') !== 'true' && e.getClientRects().length && getComputedStyle(e).visibility !== 'hidden'; + const account = [...document.querySelectorAll('[data-identifier]')].find(e => usable(e) && e.getAttribute('data-identifier').toLowerCase() === ${JSON.stringify(email.toLowerCase())}); + const consent = [...document.querySelectorAll('button,[role=button],input[type=submit]')].find(e => usable(e) && /^(continue|allow)$/i.test((e.innerText || e.value || '').trim())); + const next = account || consent; + if (!next) return false; + next.setAttribute('data-libid-google', '1'); + return true; +})()` + +/** + * Choose the account and consent on Google in `popup`, signed in by the saved + * session, until it leaves Google. A port of libid-server-rs' + * ceremony-tests/src/browser/google.rs without its sign-in: a login page means + * the saved session has expired. + */ +export async function authorizeOnGoogle(popup: Page, email: string, budgetMs = 180_000) { + const started = Date.now() + let visited = false + let clickedAt = 0 + while (Date.now() - started < budgetMs) { + if (popup.isClosed()) return + const url = URL.parse(popup.url()) + if (url?.hostname !== 'accounts.google.com') { + if (visited) return + await sleep(250) + continue + } + visited = true + const text = ( + (await popup + .locator('body') + .textContent() + .catch(() => '')) ?? '' + ).toLowerCase() + const refused = REFUSED.find(([marker]) => text.includes(marker)) + if (refused) throw new Error(`${refused[1]} (${url.pathname})`) + if (await popup.evaluate(LOGIN_VISIBLE).catch(() => false)) + throw new Error( + 'Google asked to sign in: the saved session has expired. Renew it with `ceremony export google`.', + ) + if ( + Date.now() - clickedAt > 3_000 && + (await popup.evaluate(markNext(email)).catch(() => false)) + ) { + await popup + .locator('[data-libid-google="1"]') + .click({ timeout: 5_000 }) + .catch(() => {}) + clickedAt = Date.now() + } + await sleep(250) + } + throw new Error(`No Google authorization in ${budgetMs / 1000} s; stopped on ${popup.url()}`) +} diff --git a/ts/apps/bind/e2e/session.ts b/ts/apps/bind/e2e/session.ts new file mode 100644 index 00000000..2e1c00ba --- /dev/null +++ b/ts/apps/bind/e2e/session.ts @@ -0,0 +1,74 @@ +import { readFileSync } from 'node:fs' +import { isAbsolute, join } from 'node:path' +import type { BrowserContext } from '@playwright/test' + +/** One cookie as libid-server-rs' `ceremony export` saves it; both key casings occur. */ +export interface Stored { + name: string + value: string + domain: string + path?: string + secure?: boolean | null + http_only?: boolean + httpOnly?: boolean + same_site?: string | null + sameSite?: string | null + expires?: number +} + +/** + * A saved platform session, from `_COOKIES` (base64 JSON) or + * `_COOKIES_FILE` (a path, relative to the env file's directory); + * undefined when neither is set. `required` names cookies of which at least + * one must be present for the export to hold a session. + */ +export function savedSession(prefix: string, required: string[]): Stored[] | undefined { + const encoded = process.env[`${prefix}_COOKIES`] + const file = process.env[`${prefix}_COOKIES_FILE`] + let json: string + if (encoded) json = Buffer.from(encoded.trim(), 'base64').toString('utf8') + else if (file) + json = readFileSync( + isAbsolute(file) ? file : join(process.env.LIBID_TEST_ENV_DIR ?? '.', file), + 'utf8', + ) + else if (process.env.LIBID_REQUIRE_LIVE) + throw new Error(`LIBID_REQUIRE_LIVE is set but ${prefix}_COOKIES(_FILE) is not`) + else return undefined + const parsed = JSON.parse(json) as Stored[] | { cookies: Stored[] } + const cookies = Array.isArray(parsed) ? parsed : parsed.cookies + if (!cookies.some((cookie) => required.includes(cookie.name))) + throw new Error(`The saved ${prefix} session has none of ${required.join(', ')}`) + return cookies +} + +const sameSite = (value: string | null | undefined): 'Strict' | 'Lax' | 'None' | undefined => { + const lower = value?.toLowerCase() + return lower === 'strict' + ? 'Strict' + : lower === 'lax' + ? 'Lax' + : lower === 'none' || lower === 'no_restriction' + ? 'None' + : undefined +} + +/** Put saved cookies in the browser context, each on its own domain unless `domainOf` maps it. */ +export async function restoreSession( + context: BrowserContext, + cookies: Stored[], + domainOf: (domain: string) => string = (domain) => domain, +) { + await context.addCookies( + cookies.map((cookie) => ({ + name: cookie.name, + value: cookie.value, + domain: domainOf(cookie.domain), + path: cookie.path || '/', + secure: cookie.secure ?? true, + httpOnly: cookie.http_only ?? cookie.httpOnly ?? false, + sameSite: sameSite(cookie.same_site ?? cookie.sameSite), + expires: cookie.expires && cookie.expires > 0 ? cookie.expires : -1, + })), + ) +} diff --git a/ts/apps/bind/e2e/x.ts b/ts/apps/bind/e2e/x.ts index b8a2c52e..b0f06262 100644 --- a/ts/apps/bind/e2e/x.ts +++ b/ts/apps/bind/e2e/x.ts @@ -1,72 +1,17 @@ -import { readFileSync } from 'node:fs' -import { isAbsolute, join } from 'node:path' import type { BrowserContext, Page } from '@playwright/test' - -/** One cookie as libid-server-rs' `ceremony export x` saves it. */ -interface Stored { - name: string - value: string - domain: string - path?: string - secure?: boolean | null - http_only?: boolean - same_site?: string | null - expires?: number -} +import { restoreSession, type Stored, savedSession } from './session.ts' /** - * The saved X session, from X_TEST_ALICE_COOKIES (base64 JSON) or - * X_TEST_ALICE_COOKIES_FILE (a path, relative to the env file's directory); - * undefined when neither is set. Renew it with `cargo run --bin ceremony -- - * export x` in libid-server-rs' ceremony-tests. + * The saved X session, from X_TEST_ALICE_COOKIES or X_TEST_ALICE_COOKIES_FILE. + * Renew it with `cargo run --bin ceremony -- export x` in libid-server-rs' + * ceremony-tests. */ -export function xSession(): Stored[] | undefined { - const encoded = process.env.X_TEST_ALICE_COOKIES - const file = process.env.X_TEST_ALICE_COOKIES_FILE - let json: string - if (encoded) json = Buffer.from(encoded.trim(), 'base64').toString('utf8') - else if (file) - json = readFileSync( - isAbsolute(file) ? file : join(process.env.LIBID_TEST_ENV_DIR ?? '.', file), - 'utf8', - ) - else if (process.env.LIBID_REQUIRE_LIVE) - throw new Error('LIBID_REQUIRE_LIVE is set but no saved X session is') - else return undefined - const parsed = JSON.parse(json) as Stored[] | { cookies: Stored[] } - const cookies = Array.isArray(parsed) ? parsed : parsed.cookies - if (!cookies.some((cookie) => cookie.name === 'auth_token')) - throw new Error('The saved X session has no auth_token cookie') - return cookies -} - -const sameSite = (value: string | null | undefined): 'Strict' | 'Lax' | 'None' | undefined => { - const lower = value?.toLowerCase() - return lower === 'strict' - ? 'Strict' - : lower === 'lax' - ? 'Lax' - : lower === 'none' || lower === 'no_restriction' - ? 'None' - : undefined -} +export const xSession = () => savedSession('X_TEST_ALICE', ['auth_token']) /** Put the saved session on both hosts X answers on, as the Rust suite does. */ export async function restoreXSession(context: BrowserContext, cookies: Stored[]) { - await context.addCookies( - ['x.com', 'twitter.com'].flatMap((host) => - cookies.map((cookie) => ({ - name: cookie.name, - value: cookie.value, - domain: cookie.domain.replace('x.com', host), - path: cookie.path || '/', - secure: cookie.secure ?? true, - httpOnly: cookie.http_only ?? false, - sameSite: sameSite(cookie.same_site), - expires: cookie.expires && cookie.expires > 0 ? cookie.expires : -1, - })), - ), - ) + for (const host of ['x.com', 'twitter.com']) + await restoreSession(context, cookies, (domain) => domain.replace('x.com', host)) } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) diff --git a/ts/apps/bind/keeper.toml b/ts/apps/bind/keeper.toml new file mode 100644 index 00000000..93427dfa --- /dev/null +++ b/ts/apps/bind/keeper.toml @@ -0,0 +1,10 @@ +# The keeper's one rotation on the local chain, run by stack.ts after deploy. +notary_url = "tcp://notary:7047" +# anvil account #3: funded, and not one the deploy or the tests use. A public +# test key. +signer = "7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6" + +[[networks]] +name = "local" +rpc_url = "http://anvil:8545" +google_jwt_roots = "0xb7a2ce28e71dbb9c877d2b5a48de33b5f0e6838d" diff --git a/ts/apps/bind/src/app.ts b/ts/apps/bind/src/app.ts index 7990d5fa..f96dc2a7 100644 --- a/ts/apps/bind/src/app.ts +++ b/ts/apps/bind/src/app.ts @@ -4,6 +4,7 @@ import { CeremonyStage, createCCDPClient, type IdentityResult, + type OAuthProof, } from '@libid/ceremony' import { identityRegistryAbi, platformId, resolveId } from '@libid/contracts' import { PopupWindow } from '@libid/popup' @@ -19,7 +20,12 @@ import { type WalletClient, } from 'viem' import { privateKeyToAccount } from 'viem/accounts' -import { authorizedTransactionData, encodeTlsNotaryPayload, evmLedger } from './evm.ts' +import { + authorizedTransactionData, + encodeGooglePayload, + encodeTlsNotaryPayload, + evmLedger, +} from './evm.ts' declare global { interface Window { @@ -48,9 +54,8 @@ const show = (id: string, text: string) => { const status = (text: string) => show('status', text) const connectButton = document.querySelector('#connect')! -/** The platforms whose proofs this app encodes: both are TLSNotary ceremonies. */ -type Platform = 'github' | 'x' -const names: Record = { github: 'GitHub', x: 'X' } +type Platform = 'github' | 'x' | 'google' +const names: Record = { github: 'GitHub', x: 'X', google: 'Google' } const anchors = Object.fromEntries( (Object.keys(names) as Platform[]).map((platform) => [ platform, @@ -76,9 +81,27 @@ async function connect() { show('holder', holder) } +type Accepted = Extract, { status: 'accepted' }> + +/** GitHub and X prove through TLSNotary sessions; Google through a signed ID token. */ +function payloadOf(result: Accepted, operationDomain: Uint8Array, transactionData: Uint8Array) { + return result.identity.platformId === 'google' + ? encodeGooglePayload( + result.oauthProof as OAuthProof<'google'>, + result.identity.oauthClientId, + operationDomain, + transactionData, + ) + : encodeTlsNotaryPayload( + result.oauthProof as OAuthProof<'github' | 'x'>, + operationDomain, + transactionData, + ) +} + async function submit( platform: Platform, - result: Extract, { status: 'accepted' }>, + result: Accepted, transactionData: Uint8Array, operationDomain: Uint8Array, ) { @@ -91,7 +114,7 @@ async function submit( functionName: 'quoteBind', args: [id, result.oauthProof.platformCeremonyVersion], }) - const payload = encodeTlsNotaryPayload(result.oauthProof, operationDomain, transactionData) + const payload = payloadOf(result, operationDomain, transactionData) status('Submitting the binding…') const { request } = await publicClient.simulateContract({ account: holder, diff --git a/ts/apps/bind/src/evm.ts b/ts/apps/bind/src/evm.ts index b12cd4bc..3e50b937 100644 --- a/ts/apps/bind/src/evm.ts +++ b/ts/apps/bind/src/evm.ts @@ -10,6 +10,7 @@ import { hexToBytes, keccak256, numberToHex, + stringToHex, zeroAddress, } from 'viem' @@ -80,3 +81,44 @@ export function encodeTlsNotaryPayload( }, ]) } + +/** `GooglePlatformVerifier.GoogleProof`, the payload the Google verifier decodes. */ +const googleProof = [ + { + type: 'tuple', + components: [ + { name: 'ceremonyVersion', type: 'uint16' }, + { name: 'operationDomain', type: 'bytes32' }, + { name: 'authorizationNonce', type: 'bytes32' }, + { name: 'transactionData', type: 'bytes' }, + { name: 'clientIdentifier', type: 'bytes' }, + { name: 'publicInputs', type: 'bytes32[]' }, + { name: 'proof', type: 'bytes' }, + ], + }, +] as const + +/** + * The `payload` of `IdentityRegistry.bind` for a Google ceremony. The client + * identifier is the token's audience, which the result carries on its identity + * rather than its proof. + */ +export function encodeGooglePayload( + oauthProof: OAuthProof<'google'>, + oauthClientId: string, + operationDomain: Uint8Array, + transactionData: Uint8Array, +): Hex { + const { proof } = oauthProof + return encodeAbiParameters(googleProof, [ + { + ceremonyVersion: oauthProof.platformCeremonyVersion, + operationDomain: bytesToHex(operationDomain), + authorizationNonce: bytesToHex(oauthProof.authorizationNonce), + transactionData: bytesToHex(transactionData), + clientIdentifier: stringToHex(oauthClientId), + publicInputs: proof.publicInputs as Hex[], + proof: bytesToHex(proof.identityProof), + }, + ]) +} diff --git a/ts/apps/bind/src/index.html b/ts/apps/bind/src/index.html index f77f3b3f..ac9ccf9b 100644 --- a/ts/apps/bind/src/index.html +++ b/ts/apps/bind/src/index.html @@ -25,6 +25,7 @@

Bind an identity

Bind GitHub Bind X + Bind Google

diff --git a/ts/apps/bind/stack.ts b/ts/apps/bind/stack.ts index ce3427ad..3b347c83 100644 --- a/ts/apps/bind/stack.ts +++ b/ts/apps/bind/stack.ts @@ -1,5 +1,6 @@ -// Start a local chain with the real libID contracts, the notary, Bridge, CCDP -// and the indexer; with --app, also the bind application. No OAuth mocks. +// Start a local chain with the real libID contracts and Google's keys rotated in, +// the notary, Bridge, CCDP and the indexer; with --app, also the bind +// application. No OAuth mocks. import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { existsSync, mkdirSync, writeFileSync } from 'node:fs' @@ -17,6 +18,7 @@ const composeArgs = ['compose', '-p', project, '-f', join(root, 'compose.yaml')] export const RPC_URL = 'http://127.0.0.1:4688' export const REGISTRY = '0x0531b83b010a6b0c24c2c2c1a6beecc90cc71366' as const +const GOOGLE_JWT_ROOTS = '0xb7a2ce28e71dbb9c877d2b5a48de33b5f0e6838d' as const const BRIDGE = 'http://127.0.0.1:4682' const API = 'http://127.0.0.1:4689' const APP_ORIGIN = 'http://localhost:4695' @@ -99,6 +101,34 @@ async function deploy() { if (code === '0x') throw new Error('IdentityRegistry has no code after deploy') } +const jwtRootsAbi = [ + { + type: 'function', + name: 'needsRotation', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'bool' }], + }, +] as const + +/** + * One keeper rotation: a notarized reading of Google's keys into + * GoogleJwtRoots, which the Google verifier trusts. Without it no Google proof + * verifies. + */ +async function rotateGoogleKeys() { + execFileSync('docker', [...composeArgs, '--profile', 'keeper', 'run', '--rm', 'keeper'], { + stdio: 'inherit', + }) + const chain = createPublicClient({ transport: http(RPC_URL) }) + const stale = await chain.readContract({ + address: GOOGLE_JWT_ROOTS, + abi: jwtRootsAbi, + functionName: 'needsRotation', + }) + if (stale) throw new Error('GoogleJwtRoots still needs a rotation after the keeper ran') +} + async function ready() { await until('Bridge', async () => { const response = await fetch(`${BRIDGE}/api/v1/ceremony/config`, { @@ -172,6 +202,7 @@ compose.on('exit', (code) => { try { await until('anvil', async () => (await rpc('eth_chainId', [])) === '0x7a69') await deploy() + await rotateGoogleKeys() await ready() console.info(`Stack ready: chain ${RPC_URL}, registry ${REGISTRY}, indexer ${API}`) if (process.argv.includes('--app') && !stopping) { From eb0abbbef1e168b8572ac7a422e1ccdaeb891117 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 2 Oct 2026 12:29:03 +0300 Subject: [PATCH 07/24] ci(bind): run the live bindings on pull requests Pull requests from this repository get its secrets, so the suite can run on them as well as nightly; a fork's pull request is skipped. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 696ca5a6..0f529c74 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -9,6 +9,10 @@ name: Live end-to-end # Every third-party action is pinned by commit SHA, with the tag in a comment. on: + pull_request: + paths: + - "ts/**" + - ".github/workflows/live-e2e.yml" workflow_dispatch: schedule: - cron: "23 3 * * *" @@ -24,6 +28,8 @@ permissions: jobs: bind: name: GitHub, X and Google bindings (live) + # A pull request from a fork gets no secrets, so it does not run. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 30 env: From 15df63464d52c77254275999518f6b210673521a Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 2 Oct 2026 12:36:45 +0300 Subject: [PATCH 08/24] fix(ci): set the stack log path on the step The runner context is not available in job-level env, which made the whole workflow file invalid. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 0f529c74..b96e731c 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -35,7 +35,6 @@ jobs: env: # A missing secret fails the run instead of skipping the test. LIBID_REQUIRE_LIVE: "1" - LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -46,6 +45,7 @@ jobs: - name: Bind GitHub, X and Google identities end to end env: + LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log GH_TEST_ALICE_USERNAME: ${{ secrets.GH_TEST_ALICE_USERNAME }} GH_TEST_ALICE_PASSWORD: ${{ secrets.GH_TEST_ALICE_PASSWORD }} GH_TEST_ALICE_TOTP_SECRET: ${{ secrets.GH_TEST_ALICE_TOTP_SECRET }} From 8d04f8bcb5c9397b20d6194610aa7d59897e8b81 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 12:58:46 +0300 Subject: [PATCH 09/24] fix(bind): follow main's CCDP client API client.connect now generates the connection ID from a popup opened by PopupWindow.fromAnchor, and client.new takes no ceremony ID. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/bind/src/app.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/ts/apps/bind/src/app.ts b/ts/apps/bind/src/app.ts index f96dc2a7..59bac766 100644 --- a/ts/apps/bind/src/app.ts +++ b/ts/apps/bind/src/app.ts @@ -158,29 +158,22 @@ function launch( event: MouseEvent, operationDomain: Uint8Array, ) { - const anchor = anchors[platform] if (!holder) { event.preventDefault() return } - const id = crypto.randomUUID() - const target = `ceremony-${id}` - // Window creation stays synchronous with the click. - const popup = PopupWindow.open(target) - const connection = client.connect(popup, { connectionId: id }) + // Window creation and the run's start stay synchronous with the click. + const connection = client.connect(PopupWindow.fromAnchor(event)) const transactionData = authorizedTransactionData(holder) let ceremony: Ceremony try { - ceremony = client.new(connection, id, platform, ledger, operationDomain, transactionData) + ceremony = client.new(connection, platform, ledger, operationDomain, transactionData) } catch (error) { event.preventDefault() connection.close() status(error instanceof Error ? error.message : 'Unable to start the ceremony.') return } - anchor.target = target - anchor.href = ceremony.launchUrl - if (popup.opened) event.preventDefault() const off = ceremony.onStage((update) => { show( 'stage', From 4b2f2c936e4323cef8b7e1a197c3deb25cd6ba7c Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 12:58:46 +0300 Subject: [PATCH 10/24] build(bind): deploy with libid-deploy 0.15.0 Main's ceremony proves with the libid-circuits v0.6.0 circuits, whose verifiers chain-configurations v0.15.0 deploys. Under 0.14.0's verifiers every bind reverts with SumcheckFailed. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/bind/stack.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ts/apps/bind/stack.ts b/ts/apps/bind/stack.ts index 3b347c83..31fb809d 100644 --- a/ts/apps/bind/stack.ts +++ b/ts/apps/bind/stack.ts @@ -25,9 +25,9 @@ const APP_ORIGIN = 'http://localhost:4695' /** chain-configurations' deploy tool; it deploys the real verifiers, not stubs. */ const DEPLOY = { - version: '0.14.0', - asset: 'libid-deploy-0.14.0-x86_64-unknown-linux-gnu', - sha256: '131166dcc90dcbcbb9b10d471310ed34db30062328a5e5e03789d9636b61dc9c', + version: '0.15.0', + asset: 'libid-deploy-0.15.0-x86_64-unknown-linux-gnu', + sha256: 'dc1cc678d861bf91494bcf827bd321904b6c3d5855df9821eafae2afd77914a6', } async function rpc(method: string, params: unknown[]): Promise { From d8bee4ac60c0f313002a59d665f1b2cbb9ea583f Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 2 Oct 2026 12:54:47 +0300 Subject: [PATCH 11/24] test(bind): fail fast on a failed ceremony and a Google challenge The proof wait ends as soon as the page reports the ceremony failed, with its message, instead of running out its five minutes. Google's verification pages are caught by their challenge path and by text with either apostrophe. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/bind/e2e/bind.live.spec.ts | 23 ++++++++++++++++++----- ts/apps/bind/e2e/google.ts | 8 ++++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/ts/apps/bind/e2e/bind.live.spec.ts b/ts/apps/bind/e2e/bind.live.spec.ts index d5097f5b..2c47e444 100644 --- a/ts/apps/bind/e2e/bind.live.spec.ts +++ b/ts/apps/bind/e2e/bind.live.spec.ts @@ -10,6 +10,23 @@ const RPC_URL = 'http://127.0.0.1:4688' /** anvil account #2: unlocked by anvil, funded, and not one the deploy uses. */ const HOLDER = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' +/** + * Wait for the page to show `expectedUser` as proved. Proving runs in the + * popup's Prover document after the OAuth return; a failed ceremony ends the + * wait with the page's own message. + */ +async function proofReceived(page: Page, expectedUser: string, timeoutMs = 300_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if ((await page.getByTestId('stage').textContent()) === 'failed') + throw new Error(`The ceremony failed: ${await page.getByTestId('status').textContent()}`) + const proved = await page.getByTestId('proof-received').textContent() + if (proved?.toLowerCase().includes(expectedUser.toLowerCase())) return + await page.waitForTimeout(500) + } + throw new Error(`No proof for ${expectedUser} in ${timeoutMs / 1000} s`) +} + /** * Connect the wallet, start the platform's ceremony from its link, let * `authorize` drive the popup through the platform, then check every step the @@ -35,11 +52,7 @@ async function bindThroughTheUi( ]) await authorize(popup) - // Proving runs in the popup's Prover document after the OAuth return. - await expect(page.getByTestId('proof-received')).toContainText(expectedUser, { - ignoreCase: true, - timeout: 300_000, - }) + await proofReceived(page, expectedUser) await expect(page.getByTestId('tx-hash')).toHaveText(/^0x[0-9a-f]{64}$/, { timeout: 60_000 }) await expect(page.getByTestId('receipt-status')).toHaveText(/^success in block \d+$/, { timeout: 60_000, diff --git a/ts/apps/bind/e2e/google.ts b/ts/apps/bind/e2e/google.ts index b54ed347..418f49da 100644 --- a/ts/apps/bind/e2e/google.ts +++ b/ts/apps/bind/e2e/google.ts @@ -16,7 +16,7 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const REFUSED: [string, string][] = [ ['this browser or app may not be secure', 'Google refused this browser'], ["verify it's you", 'Google asks to verify the account interactively'], - ['verify you’re human', 'Google asks to verify the account interactively'], + ["verify you're human", 'Google asks to verify the account interactively'], ['redirect_uri_mismatch', 'Google rejected the redirect URI of the OAuth client'], ['access blocked', 'Google blocked the OAuth client'], ['has not completed the google verification process', 'Google blocked the OAuth client'], @@ -64,9 +64,13 @@ export async function authorizeOnGoogle(popup: Page, email: string, budgetMs = 1 .locator('body') .textContent() .catch(() => '')) ?? '' - ).toLowerCase() + ) + .toLowerCase() + .replaceAll('’', "'") const refused = REFUSED.find(([marker]) => text.includes(marker)) if (refused) throw new Error(`${refused[1]} (${url.pathname})`) + if (url.pathname.includes('/challenge/')) + throw new Error(`Google asks to verify the account interactively (${url.pathname})`) if (await popup.evaluate(LOGIN_VISIBLE).catch(() => false)) throw new Error( 'Google asked to sign in: the saved session has expired. Renew it with `ceremony export google`.', From f002fb47ebc108206201370de0653021eec9fb0c Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 15:54:04 +0300 Subject: [PATCH 12/24] ci(bind): leave the Google binding out of the live run Google challenges the saved session when it arrives from a runner's address, so the test fails on every run there. It stays in the suite for local runs, and the workflow no longer reads the Google secrets. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index b96e731c..6e2ba43d 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -27,7 +27,7 @@ permissions: jobs: bind: - name: GitHub, X and Google bindings (live) + name: GitHub and X bindings (live) # A pull request from a fork gets no secrets, so it does not run. if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest @@ -43,7 +43,10 @@ jobs: - name: Install Chromium run: pnpm -C ts --filter @libid/bind exec playwright install --with-deps chromium - - name: Bind GitHub, X and Google identities end to end + # Google is left out: it challenges a saved session arriving from a + # runner's address ("Verify it's you"), which no automation passes. Its + # test still runs locally. + - name: Bind GitHub and X identities end to end env: LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log GH_TEST_ALICE_USERNAME: ${{ secrets.GH_TEST_ALICE_USERNAME }} @@ -52,10 +55,7 @@ jobs: X_TEST_ALICE_USERNAME: ${{ secrets.X_TEST_ALICE_USERNAME }} # The saved X session, base64 of libid-server-rs' `ceremony export x`. X_TEST_ALICE_COOKIES: ${{ secrets.X_TEST_ALICE_COOKIES }} - GOOGLE_TEST_ALICE_EMAIL: ${{ secrets.GOOGLE_TEST_ALICE_EMAIL }} - # The saved Google session, base64 of `ceremony export google`. - GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} - run: pnpm -C ts/apps/bind test:live + run: pnpm -C ts/apps/bind test:live --grep-invert Google - name: Keep the trace, video and container logs if: failure() From 2a0cc70e15f1eb45234eb99538bf222a1495f29b Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 17:10:27 +0300 Subject: [PATCH 13/24] fix(ci): never publish the test accounts' secrets from a failed live run A Playwright trace records every fill and cookie, so the public artifact of a failed run held the GitHub test password and the saved sessions. CI records no trace or video, and a step refuses the upload when any file holds the password, the TOTP secret or a saved X cookie value. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 28 +++++++++++++++++++++++++- ts/apps/bind/playwright.live.config.ts | 6 ++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 6e2ba43d..3bf78fe5 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -57,8 +57,34 @@ jobs: X_TEST_ALICE_COOKIES: ${{ secrets.X_TEST_ALICE_COOKIES }} run: pnpm -C ts/apps/bind test:live --grep-invert Google - - name: Keep the trace, video and container logs + # The artifact is public. Refuse to upload it if any file holds a secret: + # the password, the TOTP secret, or a value of the saved X session. + - name: Check the failure output for secrets + id: scan if: failure() + env: + LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log + GH_TEST_ALICE_PASSWORD: ${{ secrets.GH_TEST_ALICE_PASSWORD }} + GH_TEST_ALICE_TOTP_SECRET: ${{ secrets.GH_TEST_ALICE_TOTP_SECRET }} + X_TEST_ALICE_COOKIES: ${{ secrets.X_TEST_ALICE_COOKIES }} + run: | + node --input-type=module -e ' + import { existsSync, readdirSync, readFileSync, statSync } from "node:fs" + import { join } from "node:path" + const env = process.env + const cookies = JSON.parse(Buffer.from(env.X_TEST_ALICE_COOKIES ?? "", "base64").toString() || "[]") + const secrets = [env.GH_TEST_ALICE_PASSWORD, env.GH_TEST_ALICE_TOTP_SECRET, + ...(Array.isArray(cookies) ? cookies : cookies.cookies).map((c) => c.value)] + .filter((s) => s && s.length >= 8) + const files = (p) => !existsSync(p) ? [] : statSync(p).isDirectory() + ? readdirSync(p).flatMap((f) => files(join(p, f))) : [p] + const found = [...files("ts/apps/bind/test-results"), ...files(env.LIBID_STACK_LOGS)] + .filter((f) => { const b = readFileSync(f); return secrets.some((s) => b.includes(s)) }) + if (found.length) { console.error("Secrets in:", found.join(", ")); process.exit(1) } + ' + + - name: Keep the screenshots and container logs + if: failure() && steps.scan.outcome == 'success' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: live-e2e diff --git a/ts/apps/bind/playwright.live.config.ts b/ts/apps/bind/playwright.live.config.ts index 5e02f6ae..7299b363 100644 --- a/ts/apps/bind/playwright.live.config.ts +++ b/ts/apps/bind/playwright.live.config.ts @@ -37,8 +37,10 @@ export default defineConfig({ headless: !process.env.HEADED, // Automation X and Google can see makes them challenge; see e2e/person.ts. launchOptions: { args: ['--disable-blink-features=AutomationControlled'] }, - trace: 'retain-on-failure', + // A trace records every fill and cookie, the test accounts' password and + // sessions included, so CI keeps screenshots only. + trace: process.env.CI ? 'off' : 'retain-on-failure', screenshot: 'only-on-failure', - video: 'retain-on-failure', + video: process.env.CI ? 'off' : 'retain-on-failure', }, }) From 588674652e4ae9649e1a772ce1f22f3bf66cf9a4 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 17:21:27 +0300 Subject: [PATCH 14/24] refactor(bind): one popup driver, one constants module, a page outcome - local.ts holds the stack's addresses and ports for the stack, the app, the Vite and Playwright configs and the spec. - e2e/popup.ts drives a platform's pages until the popup leaves them, for GitHub, X and Google alike, and clicks a page-marked control. - LIBID_LIVE_PLATFORMS selects the platforms a run binds; CI binds GitHub and X, and the stack rotates Google's keys only when Google is bound. liveSecret fails a missing secret under LIBID_REQUIRE_LIVE everywhere. - The page reports one outcome, and the test waits on it instead of a hand-written loop that recognised only one terminal stage. - stack.ts keeps one chain client, drops a code check quoteBind already implies, takes needsRotation's ABI from @libid/contracts, and waits for Bridge and the indexer while the deploy runs. - The failure artifact uploads only screenshots, error notes and the stack log, after scan-secrets.ts finds no secret in them. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 31 +++----- ts/apps/bind/compose.yaml | 2 +- ts/apps/bind/e2e/bind.live.spec.ts | 45 ++++------- ts/apps/bind/e2e/github.ts | 48 ++++-------- ts/apps/bind/e2e/google.ts | 66 +++++----------- ts/apps/bind/e2e/live.ts | 20 +++++ ts/apps/bind/e2e/person.ts | 2 +- ts/apps/bind/e2e/popup.ts | 60 ++++++++++++++ ts/apps/bind/e2e/scan-secrets.ts | 35 +++++++++ ts/apps/bind/e2e/session.ts | 35 +++++---- ts/apps/bind/e2e/x.ts | 81 +++++++------------ ts/apps/bind/local.ts | 28 +++++++ ts/apps/bind/package.json | 2 +- ts/apps/bind/playwright.live.config.ts | 13 ++-- ts/apps/bind/src/app.ts | 54 +++++-------- ts/apps/bind/src/index.html | 1 + ts/apps/bind/stack.ts | 103 +++++++++++-------------- ts/apps/bind/vite.config.ts | 5 +- ts/pnpm-lock.yaml | 13 +--- 19 files changed, 336 insertions(+), 308 deletions(-) create mode 100644 ts/apps/bind/e2e/live.ts create mode 100644 ts/apps/bind/e2e/popup.ts create mode 100644 ts/apps/bind/e2e/scan-secrets.ts create mode 100644 ts/apps/bind/local.ts diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 3bf78fe5..80fcef11 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -45,9 +45,10 @@ jobs: # Google is left out: it challenges a saved session arriving from a # runner's address ("Verify it's you"), which no automation passes. Its - # test still runs locally. + # test still runs locally, and the stack rotates no Google keys here. - name: Bind GitHub and X identities end to end env: + LIBID_LIVE_PLATFORMS: github,x LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log GH_TEST_ALICE_USERNAME: ${{ secrets.GH_TEST_ALICE_USERNAME }} GH_TEST_ALICE_PASSWORD: ${{ secrets.GH_TEST_ALICE_PASSWORD }} @@ -55,33 +56,20 @@ jobs: X_TEST_ALICE_USERNAME: ${{ secrets.X_TEST_ALICE_USERNAME }} # The saved X session, base64 of libid-server-rs' `ceremony export x`. X_TEST_ALICE_COOKIES: ${{ secrets.X_TEST_ALICE_COOKIES }} - run: pnpm -C ts/apps/bind test:live --grep-invert Google + run: pnpm -C ts/apps/bind test:live - # The artifact is public. Refuse to upload it if any file holds a secret: - # the password, the TOTP secret, or a value of the saved X session. + # The artifact is public: only screenshots, Playwright's error notes and + # the container log go up, and only when none of them holds a secret. - name: Check the failure output for secrets id: scan if: failure() env: - LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log + # A session that is not configured is nothing to look for. + LIBID_REQUIRE_LIVE: "" GH_TEST_ALICE_PASSWORD: ${{ secrets.GH_TEST_ALICE_PASSWORD }} GH_TEST_ALICE_TOTP_SECRET: ${{ secrets.GH_TEST_ALICE_TOTP_SECRET }} X_TEST_ALICE_COOKIES: ${{ secrets.X_TEST_ALICE_COOKIES }} - run: | - node --input-type=module -e ' - import { existsSync, readdirSync, readFileSync, statSync } from "node:fs" - import { join } from "node:path" - const env = process.env - const cookies = JSON.parse(Buffer.from(env.X_TEST_ALICE_COOKIES ?? "", "base64").toString() || "[]") - const secrets = [env.GH_TEST_ALICE_PASSWORD, env.GH_TEST_ALICE_TOTP_SECRET, - ...(Array.isArray(cookies) ? cookies : cookies.cookies).map((c) => c.value)] - .filter((s) => s && s.length >= 8) - const files = (p) => !existsSync(p) ? [] : statSync(p).isDirectory() - ? readdirSync(p).flatMap((f) => files(join(p, f))) : [p] - const found = [...files("ts/apps/bind/test-results"), ...files(env.LIBID_STACK_LOGS)] - .filter((f) => { const b = readFileSync(f); return secrets.some((s) => b.includes(s)) }) - if (found.length) { console.error("Secrets in:", found.join(", ")); process.exit(1) } - ' + run: node ts/apps/bind/e2e/scan-secrets.ts ts/apps/bind/test-results "${{ runner.temp }}/stack.log" - name: Keep the screenshots and container logs if: failure() && steps.scan.outcome == 'success' @@ -89,7 +77,8 @@ jobs: with: name: live-e2e path: | - ts/apps/bind/test-results + ts/apps/bind/test-results/**/*.png + ts/apps/bind/test-results/**/*.md ${{ runner.temp }}/stack.log retention-days: 14 if-no-files-found: ignore diff --git a/ts/apps/bind/compose.yaml b/ts/apps/bind/compose.yaml index 79663e9f..81a2b9e1 100644 --- a/ts/apps/bind/compose.yaml +++ b/ts/apps/bind/compose.yaml @@ -103,7 +103,7 @@ services: IDENTITY_NAMES_ADDRESS: "0x0531b83b010a6b0c24c2c2c1a6beecc90cc71366" CHAIN_NAMES: local CHAIN_ID: "31337" - # anvil mines only when a transaction arrives: no confirmations to wait for. + # A local chain does not reorganize: no confirmations to wait for. CONFIRMATIONS: "0" POLL_INTERVAL_SECS: "1" depends_on: diff --git a/ts/apps/bind/e2e/bind.live.spec.ts b/ts/apps/bind/e2e/bind.live.spec.ts index 2c47e444..a12a1bdb 100644 --- a/ts/apps/bind/e2e/bind.live.spec.ts +++ b/ts/apps/bind/e2e/bind.live.spec.ts @@ -1,31 +1,17 @@ import { type BrowserContext, expect, type Page, test } from '@playwright/test' +import { RPC_URL } from '../local.ts' import { authorizeOnGitHub, githubAccount } from './github.ts' import { authorizeOnGoogle, googleSession } from './google.ts' +import { isLive, liveSecret } from './live.ts' import { presentAsPerson } from './person.ts' import { restoreSession } from './session.ts' import { injectWallet } from './wallet.ts' import { authorizeOnX, restoreXSession, xSession } from './x.ts' -const RPC_URL = 'http://127.0.0.1:4688' /** anvil account #2: unlocked by anvil, funded, and not one the deploy uses. */ const HOLDER = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' - -/** - * Wait for the page to show `expectedUser` as proved. Proving runs in the - * popup's Prover document after the OAuth return; a failed ceremony ends the - * wait with the page's own message. - */ -async function proofReceived(page: Page, expectedUser: string, timeoutMs = 300_000) { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - if ((await page.getByTestId('stage').textContent()) === 'failed') - throw new Error(`The ceremony failed: ${await page.getByTestId('status').textContent()}`) - const proved = await page.getByTestId('proof-received').textContent() - if (proved?.toLowerCase().includes(expectedUser.toLowerCase())) return - await page.waitForTimeout(500) - } - throw new Error(`No proof for ${expectedUser} in ${timeoutMs / 1000} s`) -} +/** Proving, the transaction, and the indexer's poll, after the platform returns. */ +const OUTCOME_TIMEOUT = 8 * 60_000 /** * Connect the wallet, start the platform's ceremony from its link, let @@ -52,21 +38,22 @@ async function bindThroughTheUi( ]) await authorize(popup) - await proofReceived(page, expectedUser) - await expect(page.getByTestId('tx-hash')).toHaveText(/^0x[0-9a-f]{64}$/, { timeout: 60_000 }) - await expect(page.getByTestId('receipt-status')).toHaveText(/^success in block \d+$/, { - timeout: 60_000, - }) + // The page reports one outcome when the run ends, its own message on failure. + const outcome = page.getByTestId('outcome') + await expect(outcome).not.toHaveText('—', { timeout: OUTCOME_TIMEOUT }) + await expect(outcome).toHaveText('bound') + await expect(page.getByTestId('proof-received')).toContainText(expectedUser, { ignoreCase: true }) + await expect(page.getByTestId('tx-hash')).toHaveText(/^0x[0-9a-f]{64}$/) + await expect(page.getByTestId('receipt-status')).toHaveText(/^success in block \d+$/) await expect(page.getByTestId('registry-holder')).toHaveText(HOLDER) - await expect(page.getByTestId('indexer-holder')).toHaveText(new RegExp(`^${HOLDER}$`, 'i'), { - timeout: 90_000, - }) + await expect(page.getByTestId('indexer-holder')).toHaveText(new RegExp(`^${HOLDER}$`, 'i')) } test('a GitHub identity is proved in the browser, bound on chain, and shown', async ({ page, context, }) => { + test.skip(!isLive('github'), 'GitHub is not in LIBID_LIVE_PLATFORMS') const account = githubAccount() test.skip(!account, 'GH_TEST_ALICE_USERNAME, _PASSWORD and _TOTP_SECRET are not set') await bindThroughTheUi(page, context, 'GitHub', account!.username, (popup) => @@ -78,9 +65,10 @@ test('an X identity is proved in the browser, bound on chain, and shown', async page, context, }) => { + test.skip(!isLive('x'), 'X is not in LIBID_LIVE_PLATFORMS') const session = xSession() test.skip(!session, 'X_TEST_ALICE_COOKIES or X_TEST_ALICE_COOKIES_FILE is not set') - const username = process.env.X_TEST_ALICE_USERNAME + const username = liveSecret('X_TEST_ALICE_USERNAME') test.skip(!username, 'X_TEST_ALICE_USERNAME is not set') await presentAsPerson(context) await restoreXSession(context, session!) @@ -91,9 +79,10 @@ test('a Google identity is proved in the browser, bound on chain, and shown', as page, context, }) => { + test.skip(!isLive('google'), 'Google is not in LIBID_LIVE_PLATFORMS') const session = googleSession() test.skip(!session, 'GOOGLE_TEST_ALICE_COOKIES or GOOGLE_TEST_ALICE_COOKIES_FILE is not set') - const email = process.env.GOOGLE_TEST_ALICE_EMAIL + const email = liveSecret('GOOGLE_TEST_ALICE_EMAIL') test.skip(!email, 'GOOGLE_TEST_ALICE_EMAIL is not set') await presentAsPerson(context) await restoreSession(context, session!) diff --git a/ts/apps/bind/e2e/github.ts b/ts/apps/bind/e2e/github.ts index ac14cb93..5cf22547 100644 --- a/ts/apps/bind/e2e/github.ts +++ b/ts/apps/bind/e2e/github.ts @@ -1,5 +1,7 @@ import type { Page } from '@playwright/test' import * as OTPAuth from 'otpauth' +import { liveSecret } from './live.ts' +import { drivePopup, sleep } from './popup.ts' export interface GitHubAccount { username: string @@ -7,19 +9,12 @@ export interface GitHubAccount { totpSecret: string } -/** - * The test account, from GH_TEST_ALICE_*; undefined when any is missing, - * which skips the test locally. With LIBID_REQUIRE_LIVE set, as CI sets it, a - * missing secret fails instead. - */ +/** The test account, from GH_TEST_ALICE_*; undefined when any is missing (see liveSecret). */ export function githubAccount(): GitHubAccount | undefined { - const username = process.env.GH_TEST_ALICE_USERNAME - const password = process.env.GH_TEST_ALICE_PASSWORD - const totpSecret = process.env.GH_TEST_ALICE_TOTP_SECRET - if (username && password && totpSecret) return { username, password, totpSecret } - if (process.env.LIBID_REQUIRE_LIVE) - throw new Error('LIBID_REQUIRE_LIVE is set but GH_TEST_ALICE_* are missing') - return undefined + const username = liveSecret('GH_TEST_ALICE_USERNAME') + const password = liveSecret('GH_TEST_ALICE_PASSWORD') + const totpSecret = liveSecret('GH_TEST_ALICE_TOTP_SECRET') + return username && password && totpSecret ? { username, password, totpSecret } : undefined } function totp(secret: string): string { @@ -32,8 +27,6 @@ function totp(secret: string): string { }).generate() } -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - /** What the login has done so far; each handler reads and updates it. */ interface Progress { filled: boolean @@ -118,25 +111,12 @@ async function onGitHubPage( * form once, answer the authenticator step once per 30-second code, approve the * OAuth app, and back off on GitHub's error and rate-limit pages. */ -export async function authorizeOnGitHub(popup: Page, account: GitHubAccount, budgetMs = 180_000) { - const started = Date.now() +export async function authorizeOnGitHub(popup: Page, account: GitHubAccount) { const progress: Progress = { filled: false, rateLimited: 0, errored: 0 } - while (Date.now() - started < budgetMs) { - if (popup.isClosed()) return - const url = URL.parse(popup.url()) - if (url?.hostname === 'github.com') { - const text = ( - (await popup - .locator('body') - .textContent() - .catch(() => '')) ?? '' - ).toLowerCase() - await onGitHubPage(popup, account, progress, url.pathname, text) - } else if (url && progress.filled) { - // Back from GitHub: Callback and Prover take over. - return - } - await sleep(250) - } - throw new Error(`No GitHub authorization in ${budgetMs / 1000} s; stopped on ${popup.url()}`) + await drivePopup(popup, { + name: 'GitHub', + onHost: (url) => url.hostname === 'github.com', + step: (url, text) => onGitHubPage(popup, account, progress, url.pathname, text), + done: () => progress.filled, + }) } diff --git a/ts/apps/bind/e2e/google.ts b/ts/apps/bind/e2e/google.ts index 418f49da..74b8f694 100644 --- a/ts/apps/bind/e2e/google.ts +++ b/ts/apps/bind/e2e/google.ts @@ -1,4 +1,5 @@ import type { Page } from '@playwright/test' +import { clickMarked, drivePopup } from './popup.ts' import { savedSession } from './session.ts' /** @@ -10,8 +11,6 @@ import { savedSession } from './session.ts' export const googleSession = () => savedSession('GOOGLE_TEST_ALICE', ['SID', '__Secure-1PSID', '__Secure-3PSID']) -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - /** Pages that no automation gets past: the session or the OAuth client needs a person. */ const REFUSED: [string, string][] = [ ['this browser or app may not be secure', 'Google refused this browser'], @@ -30,13 +29,13 @@ const LOGIN_VISIBLE = `[...document.querySelectorAll('input[type=password],input /** The account row for `email`, else a Continue or Allow button, marked for a real click. */ const markNext = (email: string) => `(() => { - document.querySelectorAll('[data-libid-google]').forEach(e => e.removeAttribute('data-libid-google')); + document.querySelectorAll('[data-libid-click]').forEach(e => e.removeAttribute('data-libid-click')); const usable = e => !e.disabled && e.getAttribute('aria-disabled') !== 'true' && e.getClientRects().length && getComputedStyle(e).visibility !== 'hidden'; const account = [...document.querySelectorAll('[data-identifier]')].find(e => usable(e) && e.getAttribute('data-identifier').toLowerCase() === ${JSON.stringify(email.toLowerCase())}); const consent = [...document.querySelectorAll('button,[role=button],input[type=submit]')].find(e => usable(e) && /^(continue|allow)$/i.test((e.innerText || e.value || '').trim())); const next = account || consent; if (!next) return false; - next.setAttribute('data-libid-google', '1'); + next.setAttribute('data-libid-click', '1'); return true; })()` @@ -46,46 +45,23 @@ const markNext = (email: string) => `(() => { * ceremony-tests/src/browser/google.rs without its sign-in: a login page means * the saved session has expired. */ -export async function authorizeOnGoogle(popup: Page, email: string, budgetMs = 180_000) { - const started = Date.now() - let visited = false +export async function authorizeOnGoogle(popup: Page, email: string) { let clickedAt = 0 - while (Date.now() - started < budgetMs) { - if (popup.isClosed()) return - const url = URL.parse(popup.url()) - if (url?.hostname !== 'accounts.google.com') { - if (visited) return - await sleep(250) - continue - } - visited = true - const text = ( - (await popup - .locator('body') - .textContent() - .catch(() => '')) ?? '' - ) - .toLowerCase() - .replaceAll('’', "'") - const refused = REFUSED.find(([marker]) => text.includes(marker)) - if (refused) throw new Error(`${refused[1]} (${url.pathname})`) - if (url.pathname.includes('/challenge/')) - throw new Error(`Google asks to verify the account interactively (${url.pathname})`) - if (await popup.evaluate(LOGIN_VISIBLE).catch(() => false)) - throw new Error( - 'Google asked to sign in: the saved session has expired. Renew it with `ceremony export google`.', - ) - if ( - Date.now() - clickedAt > 3_000 && - (await popup.evaluate(markNext(email)).catch(() => false)) - ) { - await popup - .locator('[data-libid-google="1"]') - .click({ timeout: 5_000 }) - .catch(() => {}) - clickedAt = Date.now() - } - await sleep(250) - } - throw new Error(`No Google authorization in ${budgetMs / 1000} s; stopped on ${popup.url()}`) + await drivePopup(popup, { + name: 'Google', + onHost: (url) => url.hostname === 'accounts.google.com', + step: async (url, body) => { + const text = body.replaceAll('’', "'") + const refused = REFUSED.find(([marker]) => text.includes(marker)) + if (refused) throw new Error(`${refused[1]} (${url.pathname})`) + if (url.pathname.includes('/challenge/')) + throw new Error(`Google asks to verify the account interactively (${url.pathname})`) + if (await popup.evaluate(LOGIN_VISIBLE).catch(() => false)) + throw new Error( + 'Google asked to sign in: the saved session has expired. Renew it with `ceremony export google`.', + ) + if (Date.now() - clickedAt > 3_000 && (await clickMarked(popup, markNext(email)))) + clickedAt = Date.now() + }, + }) } diff --git a/ts/apps/bind/e2e/live.ts b/ts/apps/bind/e2e/live.ts new file mode 100644 index 00000000..0fbb6e44 --- /dev/null +++ b/ts/apps/bind/e2e/live.ts @@ -0,0 +1,20 @@ +import { livePlatforms, type Platform } from '../local.ts' + +/** + * Whether this run binds `platform`. A platform left out of + * LIBID_LIVE_PLATFORMS is skipped on purpose, and reported as skipped. + */ +export const isLive = (platform: Platform) => livePlatforms(process.env).includes(platform) + +/** + * A test secret from the environment, or undefined, which skips the test + * locally. With LIBID_REQUIRE_LIVE set, as CI sets it, a missing secret fails + * the run instead. + */ +export function liveSecret(name: string): string | undefined { + const value = process.env[name] + if (value) return value + if (process.env.LIBID_REQUIRE_LIVE) + throw new Error(`LIBID_REQUIRE_LIVE is set but ${name} is not`) + return undefined +} diff --git a/ts/apps/bind/e2e/person.ts b/ts/apps/bind/e2e/person.ts index 656386c1..f8f829d0 100644 --- a/ts/apps/bind/e2e/person.ts +++ b/ts/apps/bind/e2e/person.ts @@ -2,7 +2,7 @@ import { fileURLToPath } from 'node:url' import type { BrowserContext, Page } from '@playwright/test' /** The Chrome these tests present as: a person's, on an Intel Mac. */ -export const USER_AGENT = +const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36' const brand = (name: string, version: string) => ({ brand: name, version }) diff --git a/ts/apps/bind/e2e/popup.ts b/ts/apps/bind/e2e/popup.ts new file mode 100644 index 00000000..c34e5f17 --- /dev/null +++ b/ts/apps/bind/e2e/popup.ts @@ -0,0 +1,60 @@ +import { setTimeout as sleep } from 'node:timers/promises' +import type { Page } from '@playwright/test' + +export { sleep } + +const BUDGET_MS = 180_000 + +export interface Driver { + /** The platform's name, for the timeout message. */ + name: string + /** Whether `url` is one of the platform's own pages. */ + onHost: (url: URL) => boolean + /** Act on one of the platform's pages; `text` is its body, lowercased. */ + step: (url: URL, text: string) => Promise + /** Whether leaving the platform now means the authorization is done. */ + done?: () => boolean +} + +/** + * Drive `popup` through a platform's pages until it leaves them: the OAuth + * return, after which Callback and Prover take over. + */ +export async function drivePopup(popup: Page, driver: Driver) { + const started = Date.now() + let visited = false + while (Date.now() - started < BUDGET_MS) { + if (popup.isClosed()) return + const url = URL.parse(popup.url()) + if (url && driver.onHost(url)) { + visited = true + const text = ( + (await popup + .locator('body') + .textContent() + .catch(() => '')) ?? '' + ).toLowerCase() + await driver.step(url, text) + } else if (url && visited && (driver.done?.() ?? true)) { + return + } + await sleep(250) + } + throw new Error( + `No ${driver.name} authorization in ${BUDGET_MS / 1000} s; stopped on ${popup.url()}`, + ) +} + +/** + * Run `mark`, a page script that tags the control to press with + * `data-libid-click` and returns whether it found one, then click that control + * for real. + */ +export async function clickMarked(popup: Page, mark: string): Promise { + if (!(await popup.evaluate(mark).catch(() => false))) return false + await popup + .locator('[data-libid-click="1"]') + .click({ timeout: 5_000 }) + .catch(() => {}) + return true +} diff --git a/ts/apps/bind/e2e/scan-secrets.ts b/ts/apps/bind/e2e/scan-secrets.ts new file mode 100644 index 00000000..26736a60 --- /dev/null +++ b/ts/apps/bind/e2e/scan-secrets.ts @@ -0,0 +1,35 @@ +// Refuse to publish a failed run's output when any file holds a test secret: +// the GitHub password or TOTP secret, or a value of a saved session. Paths +// are the arguments; directories are searched. Nothing secret is printed. +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { savedSession } from './session.ts' + +const sessions = [ + savedSession('X_TEST_ALICE', ['auth_token']), + savedSession('GOOGLE_TEST_ALICE', ['SID', '__Secure-1PSID', '__Secure-3PSID']), +] +const secrets = [ + process.env.GH_TEST_ALICE_PASSWORD, + process.env.GH_TEST_ALICE_TOTP_SECRET, + ...sessions.flatMap((cookies) => cookies?.map((cookie) => cookie.value) ?? []), +].filter((secret): secret is string => !!secret && secret.length >= 8) + +const files = (path: string): string[] => + !existsSync(path) + ? [] + : statSync(path).isDirectory() + ? readdirSync(path).flatMap((name) => files(join(path, name))) + : [path] + +const leaking = process.argv + .slice(2) + .flatMap(files) + .filter((file) => { + const bytes = readFileSync(file) + return secrets.some((secret) => bytes.includes(secret)) + }) +if (leaking.length) { + console.error(`Secrets in: ${leaking.join(', ')}`) + process.exit(1) +} diff --git a/ts/apps/bind/e2e/session.ts b/ts/apps/bind/e2e/session.ts index 2e1c00ba..5bdfc9af 100644 --- a/ts/apps/bind/e2e/session.ts +++ b/ts/apps/bind/e2e/session.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import type { BrowserContext } from '@playwright/test' +import { liveSecret } from './live.ts' /** One cookie as libid-server-rs' `ceremony export` saves it; both key casings occur. */ export interface Stored { @@ -32,9 +33,11 @@ export function savedSession(prefix: string, required: string[]): Stored[] | und isAbsolute(file) ? file : join(process.env.LIBID_TEST_ENV_DIR ?? '.', file), 'utf8', ) - else if (process.env.LIBID_REQUIRE_LIVE) - throw new Error(`LIBID_REQUIRE_LIVE is set but ${prefix}_COOKIES(_FILE) is not`) - else return undefined + else { + // Neither is set: fails under LIBID_REQUIRE_LIVE, skips otherwise. + liveSecret(`${prefix}_COOKIES`) + return undefined + } const parsed = JSON.parse(json) as Stored[] | { cookies: Stored[] } const cookies = Array.isArray(parsed) ? parsed : parsed.cookies if (!cookies.some((cookie) => required.includes(cookie.name))) @@ -53,22 +56,24 @@ const sameSite = (value: string | null | undefined): 'Strict' | 'Lax' | 'None' | : undefined } -/** Put saved cookies in the browser context, each on its own domain unless `domainOf` maps it. */ +/** Put saved cookies in the browser context, on their own domain or each of `domainsOf`'s. */ export async function restoreSession( context: BrowserContext, cookies: Stored[], - domainOf: (domain: string) => string = (domain) => domain, + domainsOf: (domain: string) => string[] = (domain) => [domain], ) { await context.addCookies( - cookies.map((cookie) => ({ - name: cookie.name, - value: cookie.value, - domain: domainOf(cookie.domain), - path: cookie.path || '/', - secure: cookie.secure ?? true, - httpOnly: cookie.http_only ?? cookie.httpOnly ?? false, - sameSite: sameSite(cookie.same_site ?? cookie.sameSite), - expires: cookie.expires && cookie.expires > 0 ? cookie.expires : -1, - })), + cookies.flatMap((cookie) => + domainsOf(cookie.domain).map((domain) => ({ + name: cookie.name, + value: cookie.value, + domain, + path: cookie.path || '/', + secure: cookie.secure ?? true, + httpOnly: cookie.http_only ?? cookie.httpOnly ?? false, + sameSite: sameSite(cookie.same_site ?? cookie.sameSite), + expires: cookie.expires && cookie.expires > 0 ? cookie.expires : -1, + })), + ), ) } diff --git a/ts/apps/bind/e2e/x.ts b/ts/apps/bind/e2e/x.ts index b0f06262..f4f1336e 100644 --- a/ts/apps/bind/e2e/x.ts +++ b/ts/apps/bind/e2e/x.ts @@ -1,4 +1,5 @@ import type { BrowserContext, Page } from '@playwright/test' +import { clickMarked, drivePopup } from './popup.ts' import { restoreSession, type Stored, savedSession } from './session.ts' /** @@ -9,12 +10,8 @@ import { restoreSession, type Stored, savedSession } from './session.ts' export const xSession = () => savedSession('X_TEST_ALICE', ['auth_token']) /** Put the saved session on both hosts X answers on, as the Rust suite does. */ -export async function restoreXSession(context: BrowserContext, cookies: Stored[]) { - for (const host of ['x.com', 'twitter.com']) - await restoreSession(context, cookies, (domain) => domain.replace('x.com', host)) -} - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) +export const restoreXSession = (context: BrowserContext, cookies: Stored[]) => + restoreSession(context, cookies, (domain) => [domain, domain.replace('x.com', 'twitter.com')]) const CHALLENGE = [ 'performing security verification', @@ -33,16 +30,16 @@ const ACCEPT_COOKIES = `(() => { /** The visible, enabled consent control marked for a real click; X serves more than one consent page. */ const MARK_CONSENT = `(() => { - document.querySelectorAll('[data-libid-consent]').forEach(e => e.removeAttribute('data-libid-consent')); + document.querySelectorAll('[data-libid-click]').forEach(e => e.removeAttribute('data-libid-click')); const button = [...document.querySelectorAll("[data-testid='OAuth_Consent_Button'],button,[role=button],input[type=submit],a")].find(e => (e.getAttribute('data-testid') === 'OAuth_Consent_Button' || /^(authorize app)$/i.test((e.innerText || e.value || '').trim())) && !e.disabled && e.getAttribute('aria-disabled') !== 'true' && e.getClientRects().length > 0); - if (button) { button.setAttribute('data-libid-consent', '1'); return true; } + if (button) { button.setAttribute('data-libid-click', '1'); return true; } return false; })()` -const onX = (url: URL | null) => - url?.hostname === 'x.com' || url?.hostname === 'twitter.com' || url?.hostname?.endsWith('.x.com') +const onX = (url: URL) => + url.hostname === 'x.com' || url.hostname === 'twitter.com' || url.hostname.endsWith('.x.com') /** * Approve the OAuth app on X in `popup`, signed in by the saved session, until @@ -50,49 +47,27 @@ const onX = (url: URL | null) => * without its interactive sign-in: a login page means the saved session has * expired. */ -export async function authorizeOnX(popup: Page, budgetMs = 180_000) { - const started = Date.now() - let visited = false - let consentedAt: number | undefined +export async function authorizeOnX(popup: Page) { + let consentedAt = 0 let challengedAt: number | undefined - while (Date.now() - started < budgetMs) { - if (popup.isClosed()) return - const url = URL.parse(popup.url()) - if (!onX(url)) { - if (visited) return - await sleep(250) - continue - } - visited = true - const text = ( - (await popup - .locator('body') - .textContent() - .catch(() => '')) ?? '' - ).toLowerCase() - if (CHALLENGE.some((marker) => text.includes(marker))) { - challengedAt ??= Date.now() - if (Date.now() - challengedAt > 90_000) - throw new Error('X security verification did not clear within 90 s') - } else if (url!.pathname.startsWith('/i/flow/login') || url!.pathname === '/login') { - throw new Error( - 'X asked to sign in: the saved session has expired. Renew it with `ceremony export x`.', - ) - } else if (url!.pathname.startsWith('/i/oauth2/authorize')) { - challengedAt = undefined - await popup.evaluate(ACCEPT_COOKIES).catch(() => {}) - if ( - (consentedAt === undefined || Date.now() - consentedAt > 10_000) && - (await popup.evaluate(MARK_CONSENT).catch(() => false)) - ) { - await popup - .locator('[data-libid-consent="1"]') - .click({ timeout: 5_000 }) - .catch(() => {}) - consentedAt = Date.now() + await drivePopup(popup, { + name: 'X', + onHost: onX, + step: async (url, text) => { + if (CHALLENGE.some((marker) => text.includes(marker))) { + challengedAt ??= Date.now() + if (Date.now() - challengedAt > 90_000) + throw new Error('X security verification did not clear within 90 s') + } else if (url.pathname.startsWith('/i/flow/login') || url.pathname === '/login') { + throw new Error( + 'X asked to sign in: the saved session has expired. Renew it with `ceremony export x`.', + ) + } else if (url.pathname.startsWith('/i/oauth2/authorize')) { + challengedAt = undefined + await popup.evaluate(ACCEPT_COOKIES).catch(() => {}) + if (Date.now() - consentedAt > 10_000 && (await clickMarked(popup, MARK_CONSENT))) + consentedAt = Date.now() } - } - await sleep(250) - } - throw new Error(`No X authorization in ${budgetMs / 1000} s; stopped on ${popup.url()}`) + }, + }) } diff --git a/ts/apps/bind/local.ts b/ts/apps/bind/local.ts new file mode 100644 index 00000000..a72e7d70 --- /dev/null +++ b/ts/apps/bind/local.ts @@ -0,0 +1,28 @@ +// The local stack's addresses and ports, for the stack, the app and the tests. +// compose.yaml, keeper.toml and bridge-config.toml repeat what they need. + +/** anvil, published on the host. */ +export const RPC_URL = 'http://127.0.0.1:4688' +export const CHAIN_ID = 31337 +/** The bind app, served by Vite; Bridge admits this origin. */ +export const APP_ORIGIN = 'http://localhost:4695' +export const APP_PORT = 4695 +export const BRIDGE = 'http://localhost:4682' +export const NOTARY = 'http://localhost:4687' +/** The indexer's read API. */ +export const API = 'http://127.0.0.1:4689' + +/** chain-configurations' local-dev.toml at v0.15.0. */ +export const REGISTRY = '0x0531b83b010a6b0c24c2c2c1a6beecc90cc71366' +export const GOOGLE_JWT_ROOTS = '0xb7a2ce28e71dbb9c877d2b5a48de33b5f0e6838d' + +export type Platform = 'github' | 'x' | 'google' + +/** + * The platforms a live run binds: LIBID_LIVE_PLATFORMS, comma-separated, or + * all three. The stack rotates Google's keys only when Google is among them. + */ +export function livePlatforms(env: Record): Platform[] { + const listed = env.LIBID_LIVE_PLATFORMS?.split(',').map((name) => name.trim()) + return (listed ?? ['github', 'x', 'google']) as Platform[] +} diff --git a/ts/apps/bind/package.json b/ts/apps/bind/package.json index 14f78142..4bb47287 100644 --- a/ts/apps/bind/package.json +++ b/ts/apps/bind/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@libid/ceremony": "workspace:^", - "@libid/contracts": "0.15.0", + "@libid/contracts": "0.15.1", "@libid/ledger": "workspace:^", "@libid/popup": "workspace:^", "viem": "^2.48.11" diff --git a/ts/apps/bind/playwright.live.config.ts b/ts/apps/bind/playwright.live.config.ts index 7299b363..469b777f 100644 --- a/ts/apps/bind/playwright.live.config.ts +++ b/ts/apps/bind/playwright.live.config.ts @@ -1,17 +1,16 @@ import { readFileSync } from 'node:fs' import { dirname } from 'node:path' +import { parseEnv } from 'node:util' import { defineConfig, devices } from '@playwright/test' +import { APP_ORIGIN } from './local.ts' // Test secrets from a dotenv file when LIBID_TEST_ENV_FILE names one; values // already in the environment win. Nothing is printed. const envFile = process.env.LIBID_TEST_ENV_FILE if (envFile) { process.env.LIBID_TEST_ENV_DIR ??= dirname(envFile) - for (const line of readFileSync(envFile, 'utf8').split('\n')) { - const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line) - if (match && process.env[match[1]!] === undefined) - process.env[match[1]!] = match[2]!.replace(/^(['"])(.*)\1$/, '$2') - } + for (const [name, value] of Object.entries(parseEnv(readFileSync(envFile, 'utf8')))) + process.env[name] ??= value } /** Live: real platforms, real notary, real chain. */ @@ -25,7 +24,7 @@ export default defineConfig({ // already running locally is reused. webServer: { command: 'pnpm dev', - url: 'http://localhost:4695', + url: APP_ORIGIN, reuseExistingServer: !process.env.CI, timeout: 15 * 60_000, gracefulShutdown: { signal: 'SIGTERM', timeout: 120_000 }, @@ -33,7 +32,7 @@ export default defineConfig({ }, use: { ...devices['Desktop Chrome'], - baseURL: 'http://localhost:4695', + baseURL: APP_ORIGIN, headless: !process.env.HEADED, // Automation X and Google can see makes them challenge; see e2e/person.ts. launchOptions: { args: ['--disable-blink-features=AutomationControlled'] }, diff --git a/ts/apps/bind/src/app.ts b/ts/apps/bind/src/app.ts index 59bac766..97f5924c 100644 --- a/ts/apps/bind/src/app.ts +++ b/ts/apps/bind/src/app.ts @@ -15,11 +15,13 @@ import { custom, defineChain, type EIP1193Provider, + getContract, hexToBytes, http, type WalletClient, } from 'viem' import { privateKeyToAccount } from 'viem/accounts' +import { BRIDGE, CHAIN_ID, NOTARY, type Platform, REGISTRY, RPC_URL } from '../local.ts' import { authorizedTransactionData, encodeGooglePayload, @@ -33,18 +35,14 @@ declare global { } } -const RPC_URL = import.meta.env.VITE_RPC_URL ?? 'http://127.0.0.1:4688' -const REGISTRY: Address = '0x0531b83b010a6b0c24c2c2c1a6beecc90cc71366' -const BRIDGE = 'http://localhost:4682' -const NOTARY = 'http://localhost:4687' const chain = defineChain({ - id: 31337, + id: CHAIN_ID, name: 'Local', nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, rpcUrls: { default: { http: [RPC_URL] } }, }) const publicClient = createPublicClient({ chain, transport: http(RPC_URL) }) -const registry = { client: publicClient, address: REGISTRY } +const registry = getContract({ address: REGISTRY, abi: identityRegistryAbi, client: publicClient }) const ledger = evmLedger(chain.id, NOTARY) const field = (id: string) => document.querySelector(`[data-testid="${id}"]`)! @@ -54,7 +52,6 @@ const show = (id: string, text: string) => { const status = (text: string) => show('status', text) const connectButton = document.querySelector('#connect')! -type Platform = 'github' | 'x' | 'google' const names: Record = { github: 'GitHub', x: 'X', google: 'Google' } const anchors = Object.fromEntries( (Object.keys(names) as Platform[]).map((platform) => [ @@ -108,20 +105,12 @@ async function submit( if (!wallet || !holder) throw new Error('Wallet disconnected') show('proof-received', `${result.identity.userName} (${result.identity.userId})`) const id = platformId(platform) - const value = await publicClient.readContract({ - address: REGISTRY, - abi: identityRegistryAbi, - functionName: 'quoteBind', - args: [id, result.oauthProof.platformCeremonyVersion], - }) + const version = result.oauthProof.platformCeremonyVersion + const value = await registry.read.quoteBind([id, version]) const payload = payloadOf(result, operationDomain, transactionData) status('Submitting the binding…') - const { request } = await publicClient.simulateContract({ + const { request } = await registry.simulate.bind([id, version, payload, true], { account: holder, - address: REGISTRY, - abi: identityRegistryAbi, - functionName: 'bind', - args: [id, result.oauthProof.platformCeremonyVersion, payload, true], value, }) const hash = await wallet.writeContract({ ...request, account: wallet.account ?? holder }) @@ -130,11 +119,16 @@ async function submit( show('receipt-status', `${receipt.status} in block ${receipt.blockNumber}`) if (receipt.status !== 'success') throw new Error('The binding transaction reverted') - const bound = await resolveId(registry, id, result.identity.userId) + const bound = await resolveId( + { client: publicClient, address: REGISTRY }, + id, + result.identity.userId, + ) show('registry-holder', bound ?? 'not bound') status('Bound on chain. Waiting for the indexer…') show('indexer-holder', await indexed(platform, result.identity.userId)) status('Done.') + show('outcome', 'bound') } /** The holder the indexer reports for an id, polled until it appears. */ @@ -186,15 +180,15 @@ function launch( .proveUserIdentity() .then(async (result) => { connection.close() - if (result.status !== 'accepted') { - status(`${names[platform]} denied the authorization.`) - return - } + if (result.status !== 'accepted') + throw new Error(`${names[platform]} denied the authorization.`) await submit(platform, result, transactionData, operationDomain) }) - .catch((error: unknown) => - status(error instanceof Error ? error.message : 'The binding failed.'), - ) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : 'The binding failed.' + status(message) + show('outcome', `failed: ${message}`) + }) .finally(off) } @@ -203,13 +197,7 @@ async function initialize() { const enabled = (Object.keys(names) as Platform[]).filter((platform) => client.enabledPlatforms.includes(platform), ) - const operationDomain = hexToBytes( - await publicClient.readContract({ - address: REGISTRY, - abi: identityRegistryAbi, - functionName: 'OPERATION_DOMAIN', - }), - ) + const operationDomain = hexToBytes(await registry.read.OPERATION_DOMAIN()) connectButton.addEventListener('click', () => { connect().then( () => { diff --git a/ts/apps/bind/src/index.html b/ts/apps/bind/src/index.html index ac9ccf9b..482b6c89 100644 --- a/ts/apps/bind/src/index.html +++ b/ts/apps/bind/src/index.html @@ -36,6 +36,7 @@

Bind an identity

Receipt
—
Registry says
—
Indexer says
—
+
Outcome
—
diff --git a/ts/apps/bind/stack.ts b/ts/apps/bind/stack.ts index 31fb809d..8b7ce6c0 100644 --- a/ts/apps/bind/stack.ts +++ b/ts/apps/bind/stack.ts @@ -1,27 +1,33 @@ -// Start a local chain with the real libID contracts and Google's keys rotated in, -// the notary, Bridge, CCDP and the indexer; with --app, also the bind -// application. No OAuth mocks. +// Start a local chain with the real libID contracts and, when this run binds +// Google (LIBID_LIVE_PLATFORMS), Google's keys rotated in; the notary, Bridge, +// CCDP and the indexer; with --app, also the bind application. No OAuth mocks. import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath } from 'node:url' -import { identityRegistryAbi, platformId } from '@libid/contracts' +import { googleJwtRootsAbi, identityRegistryAbi, platformId } from '@libid/contracts' import { createPublicClient, http } from 'viem' import { createServer as createViteServer, type ViteDevServer } from 'vite' +import { + API, + APP_ORIGIN, + CHAIN_ID, + GOOGLE_JWT_ROOTS, + livePlatforms, + REGISTRY, + RPC_URL, +} from './local.ts' const root = fileURLToPath(new URL('.', import.meta.url)) const cache = join(root, '.cache') const project = `libid-bind-${createHash('sha256').update(root).digest('hex').slice(0, 12)}` const composeArgs = ['compose', '-p', project, '-f', join(root, 'compose.yaml')] -export const RPC_URL = 'http://127.0.0.1:4688' -export const REGISTRY = '0x0531b83b010a6b0c24c2c2c1a6beecc90cc71366' as const -const GOOGLE_JWT_ROOTS = '0xb7a2ce28e71dbb9c877d2b5a48de33b5f0e6838d' as const +// Docker publishes on 127.0.0.1; the browser reaches Bridge as localhost. const BRIDGE = 'http://127.0.0.1:4682' -const API = 'http://127.0.0.1:4689' -const APP_ORIGIN = 'http://localhost:4695' +const chain = createPublicClient({ transport: http(RPC_URL) }) /** chain-configurations' deploy tool; it deploys the real verifiers, not stubs. */ const DEPLOY = { @@ -30,16 +36,15 @@ const DEPLOY = { sha256: 'dc1cc678d861bf91494bcf827bd321904b6c3d5855df9821eafae2afd77914a6', } -async function rpc(method: string, params: unknown[]): Promise { - const response = await fetch(RPC_URL, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), - signal: AbortSignal.timeout(2000), +/** Run a command to completion, its output on ours; reject on a nonzero exit. */ +function run(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: 'inherit' }) + child.on('error', reject) + child.on('exit', (code) => + code === 0 ? resolve() : reject(new Error(`${command} ${args[0]} exited with ${code}`)), + ) }) - const body = (await response.json()) as { result?: unknown; error?: { message: string } } - if (body.error) throw new Error(body.error.message) - return body.result } async function until(what: string, check: () => Promise, seconds = 120) { @@ -73,21 +78,16 @@ async function deployTool(): Promise { /** Deploy the stack to the fresh anvil, then check what the app relies on. */ async function deploy() { const tool = await deployTool() - execFileSync( - tool, - [ - 'apply', - '--network', - join(root, 'local-dev.toml'), - '--rpc-url', - RPC_URL, - '--yes', - '--confirm-fresh-deploy', - '--dev', - ], - { stdio: 'inherit' }, - ) - const chain = createPublicClient({ transport: http(RPC_URL) }) + await run(tool, [ + 'apply', + '--network', + join(root, 'local-dev.toml'), + '--rpc-url', + RPC_URL, + '--yes', + '--confirm-fresh-deploy', + '--dev', + ]) // Two notary fees: the GitHub verifier is registered and the notary service wired. const quote = await chain.readContract({ address: REGISTRY, @@ -97,40 +97,25 @@ async function deploy() { }) if (quote !== 2_000_000_000_000_000n) throw new Error(`quoteBind(github, 1) is ${quote}, expected 2e15`) - const code = (await rpc('eth_getCode', [REGISTRY, 'latest'])) as string - if (code === '0x') throw new Error('IdentityRegistry has no code after deploy') } -const jwtRootsAbi = [ - { - type: 'function', - name: 'needsRotation', - stateMutability: 'view', - inputs: [], - outputs: [{ type: 'bool' }], - }, -] as const - /** * One keeper rotation: a notarized reading of Google's keys into * GoogleJwtRoots, which the Google verifier trusts. Without it no Google proof * verifies. */ async function rotateGoogleKeys() { - execFileSync('docker', [...composeArgs, '--profile', 'keeper', 'run', '--rm', 'keeper'], { - stdio: 'inherit', - }) - const chain = createPublicClient({ transport: http(RPC_URL) }) + await run('docker', [...composeArgs, '--profile', 'keeper', 'run', '--rm', 'keeper']) const stale = await chain.readContract({ address: GOOGLE_JWT_ROOTS, - abi: jwtRootsAbi, + abi: googleJwtRootsAbi, functionName: 'needsRotation', }) if (stale) throw new Error('GoogleJwtRoots still needs a rotation after the keeper ran') } async function ready() { - await until('Bridge', async () => { + const bridge = until('Bridge', async () => { const response = await fetch(`${BRIDGE}/api/v1/ceremony/config`, { headers: { Origin: APP_ORIGIN }, redirect: 'error', @@ -139,11 +124,18 @@ async function ready() { await response.body?.cancel() return response.status === 200 }) - await until('indexer API', async () => { + const api = until('indexer API', async () => { const response = await fetch(`${API}/v1/status`, { signal: AbortSignal.timeout(1000) }) await response.body?.cancel() return response.status === 200 }) + await Promise.all([bridge, api]) +} + +/** The chain's contracts, then Google's keys when this run binds Google. */ +async function chainReady() { + await deploy() + if (livePlatforms(process.env).includes('google')) await rotateGoogleKeys() } // ─── Main ─────────────────────────────────────────────────────── @@ -200,10 +192,9 @@ compose.on('exit', (code) => { }) try { - await until('anvil', async () => (await rpc('eth_chainId', [])) === '0x7a69') - await deploy() - await rotateGoogleKeys() - await ready() + await until('anvil', async () => (await chain.getChainId()) === CHAIN_ID) + // Bridge and the indexer need neither the deploy nor the keeper. + await Promise.all([chainReady(), ready()]) console.info(`Stack ready: chain ${RPC_URL}, registry ${REGISTRY}, indexer ${API}`) if (process.argv.includes('--app') && !stopping) { frontend = await createViteServer({ configFile: join(root, 'vite.config.ts') }) diff --git a/ts/apps/bind/vite.config.ts b/ts/apps/bind/vite.config.ts index f5c6101f..0d1653d6 100644 --- a/ts/apps/bind/vite.config.ts +++ b/ts/apps/bind/vite.config.ts @@ -1,6 +1,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' +import { API, APP_PORT } from './local.ts' const root = fileURLToPath(new URL('.', import.meta.url)) const directory = join(root, '.cache/bind') @@ -10,12 +11,12 @@ export default defineConfig({ envDir: root, server: { host: 'localhost', - port: 4695, + port: APP_PORT, strictPort: true, // The indexer's read API, same-origin so the page needs no CORS from it. proxy: { '/indexer': { - target: 'http://127.0.0.1:4689', + target: API, rewrite: (path) => path.replace(/^\/indexer/, ''), }, }, diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index f1e2a706..d7287f27 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: workspace:^ version: link:../../packages/ceremony '@libid/contracts': - specifier: 0.15.0 - version: 0.15.0(viem@2.57.3(typescript@7.0.2)) + specifier: 0.15.1 + version: 0.15.1(viem@2.57.3(typescript@7.0.2)) '@libid/ledger': specifier: workspace:^ version: link:../../packages/ledger @@ -437,11 +437,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@libid/contracts@0.15.0': - resolution: {integrity: sha512-TOthZejhxj7NdY2Fr6dwgt+TVGEPuTSVSBcTJIvK7ZMQqJY4Ugwq9av20lfbj2qse9qbIVsknFRUr8QFfDfhoQ==} - peerDependencies: - viem: ^2.48.11 - '@libid/contracts@0.15.1': resolution: {integrity: sha512-jBq+srAfauhYW068qB+XXnZohbLmC6TEppFCDD40bnsebAwHk1/jmzXdEtHk6G9yaA74rIs4FS7GhkkBsZOjAQ==} peerDependencies: @@ -1476,10 +1471,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 - '@libid/contracts@0.15.0(viem@2.57.3(typescript@7.0.2))': - dependencies: - viem: 2.57.3(typescript@7.0.2) - '@libid/contracts@0.15.1(viem@2.57.3(typescript@7.0.2))': optionalDependencies: viem: 2.57.3(typescript@7.0.2) From 96d47c80d02f2b3e0168ccd334a3ae1e9954fc29 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 17:45:09 +0300 Subject: [PATCH 15/24] fix(bind): bind through the verifier slot, with the wallet the run started with - quoteBind and bind route on VERIFIER_VERSION, the CeremonyProofVerifier slot libid-deploy registers every launch verifier at (its LAUNCH_VERIFIER_VERSION, 1), instead of the ceremony version the proof reports. The two are different numbers that happen to agree at launch. - submit() takes the wallet and holder captured when the ceremony started, the holder its transaction data names, instead of re-reading the module's current ones. - The popup connection closes however the run ends, a rejection included. - A failed start reports its own error message rather than always blaming the Bridge configuration. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/bind/local.ts | 8 ++++++++ ts/apps/bind/src/app.ts | 41 ++++++++++++++++++++++++++++++----------- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/ts/apps/bind/local.ts b/ts/apps/bind/local.ts index a72e7d70..abb2dba7 100644 --- a/ts/apps/bind/local.ts +++ b/ts/apps/bind/local.ts @@ -15,6 +15,14 @@ export const API = 'http://127.0.0.1:4689' /** chain-configurations' local-dev.toml at v0.15.0. */ export const REGISTRY = '0x0531b83b010a6b0c24c2c2c1a6beecc90cc71366' export const GOOGLE_JWT_ROOTS = '0xb7a2ce28e71dbb9c877d2b5a48de33b5f0e6838d' +/** + * The CeremonyProofVerifier slot each platform's verifier sits in: the + * `verifierVersion` that quoteBind and bind route on. libid-deploy registers + * every launch verifier at slot 1 (its LAUNCH_VERIFIER_VERSION). A slot is + * this chain's governance choice, not the ceremony version a proof reports, + * though both are 1 at launch. + */ +export const VERIFIER_VERSION = 1 export type Platform = 'github' | 'x' | 'google' diff --git a/ts/apps/bind/src/app.ts b/ts/apps/bind/src/app.ts index 97f5924c..3cd41536 100644 --- a/ts/apps/bind/src/app.ts +++ b/ts/apps/bind/src/app.ts @@ -21,7 +21,15 @@ import { type WalletClient, } from 'viem' import { privateKeyToAccount } from 'viem/accounts' -import { BRIDGE, CHAIN_ID, NOTARY, type Platform, REGISTRY, RPC_URL } from '../local.ts' +import { + BRIDGE, + CHAIN_ID, + NOTARY, + type Platform, + REGISTRY, + RPC_URL, + VERIFIER_VERSION, +} from '../local.ts' import { authorizedTransactionData, encodeGooglePayload, @@ -96,20 +104,25 @@ function payloadOf(result: Accepted, operationDomain: Uint8Array, transactionDat ) } +/** The wallet and holder a ceremony started with; its transaction data names this holder. */ +interface Signer { + wallet: WalletClient + holder: Address +} + async function submit( platform: Platform, + { wallet, holder }: Signer, result: Accepted, transactionData: Uint8Array, operationDomain: Uint8Array, ) { - if (!wallet || !holder) throw new Error('Wallet disconnected') show('proof-received', `${result.identity.userName} (${result.identity.userId})`) const id = platformId(platform) - const version = result.oauthProof.platformCeremonyVersion - const value = await registry.read.quoteBind([id, version]) + const value = await registry.read.quoteBind([id, VERIFIER_VERSION]) const payload = payloadOf(result, operationDomain, transactionData) status('Submitting the binding…') - const { request } = await registry.simulate.bind([id, version, payload, true], { + const { request } = await registry.simulate.bind([id, VERIFIER_VERSION, payload, true], { account: holder, value, }) @@ -152,13 +165,14 @@ function launch( event: MouseEvent, operationDomain: Uint8Array, ) { - if (!holder) { + if (!wallet || !holder) { event.preventDefault() return } + const signer: Signer = { wallet, holder } // Window creation and the run's start stay synchronous with the click. const connection = client.connect(PopupWindow.fromAnchor(event)) - const transactionData = authorizedTransactionData(holder) + const transactionData = authorizedTransactionData(signer.holder) let ceremony: Ceremony try { ceremony = client.new(connection, platform, ledger, operationDomain, transactionData) @@ -182,14 +196,17 @@ function launch( connection.close() if (result.status !== 'accepted') throw new Error(`${names[platform]} denied the authorization.`) - await submit(platform, result, transactionData, operationDomain) + await submit(platform, signer, result, transactionData, operationDomain) }) .catch((error: unknown) => { const message = error instanceof Error ? error.message : 'The binding failed.' status(message) show('outcome', `failed: ${message}`) }) - .finally(off) + .finally(() => { + connection.close() + off() + }) } async function initialize() { @@ -215,6 +232,8 @@ async function initialize() { status('Connect a wallet to start.') } -void initialize().catch(() => - status('Could not load the Bridge configuration. Is the stack running?'), +void initialize().catch((error: unknown) => + status( + `Could not start: ${error instanceof Error ? error.message : String(error)}. Is the stack running?`, + ), ) From 77f1c372f708c5c2929bd96a1aee717aff00d165 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 17:45:09 +0300 Subject: [PATCH 16/24] fix(bind): start the stack fresh, and never over another one - Before `up`, `compose down --volumes --remove-orphans` removes what an interrupted run of this checkout left, so the chain and the indexer's database start empty. - With any port the stack publishes (anvil, notary, Bridge, indexer API) still taken after that, startup refuses: the anvil check could pass against another checkout's chain and the deploy would go to it. - Once the stack is stopping, every wait rejects and no later step runs: no deploy, keeper or Vite server after Compose has exited; a Vite server created during a stop is closed instead of listening. - `down` reports when Docker cannot run it. - The deploy check also requires a verifier at VERIFIER_VERSION for every platform the run binds. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/bind/stack.ts | 103 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 91 insertions(+), 12 deletions(-) diff --git a/ts/apps/bind/stack.ts b/ts/apps/bind/stack.ts index 8b7ce6c0..8d9d54e9 100644 --- a/ts/apps/bind/stack.ts +++ b/ts/apps/bind/stack.ts @@ -4,10 +4,16 @@ import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { connect } from 'node:net' import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath } from 'node:url' -import { googleJwtRootsAbi, identityRegistryAbi, platformId } from '@libid/contracts' +import { + ceremonyProofVerifierAbi, + googleJwtRootsAbi, + identityRegistryAbi, + platformId, +} from '@libid/contracts' import { createPublicClient, http } from 'viem' import { createServer as createViteServer, type ViteDevServer } from 'vite' import { @@ -16,8 +22,10 @@ import { CHAIN_ID, GOOGLE_JWT_ROOTS, livePlatforms, + NOTARY, REGISTRY, RPC_URL, + VERIFIER_VERSION, } from './local.ts' const root = fileURLToPath(new URL('.', import.meta.url)) @@ -47,9 +55,13 @@ function run(command: string, args: string[]): Promise { }) } +let stopping = false + +/** Poll `check` until it holds; reject on the deadline or once the stack is stopping. */ async function until(what: string, check: () => Promise, seconds = 120) { const deadline = Date.now() + seconds * 1000 while (Date.now() < deadline) { + if (stopping) throw new Error(`Stopped while waiting for ${what}`) try { if (await check()) return } catch { @@ -75,6 +87,33 @@ async function deployTool(): Promise { return binary } +/** Whether something on this host accepts connections on `port`. */ +function listening(port: number): Promise { + return new Promise((resolve) => { + const socket = connect(port, '127.0.0.1') + socket.once('connect', () => { + socket.destroy() + resolve(true) + }) + socket.once('error', () => resolve(false)) + }) +} + +/** + * Refuse to start over another stack: with a port this project publishes + * already taken, a check could pass against that stack, and the deploy would + * go to its chain. + */ +async function portsFree() { + const ports = [RPC_URL, NOTARY, BRIDGE, API].map((url) => Number(new URL(url).port)) + const taken = [] + for (const port of ports) if (await listening(port)) taken.push(port) + if (taken.length) + throw new Error( + `Already in use on 127.0.0.1: port ${taken.join(', ')}. Stop the stack holding it (see docker ps).`, + ) +} + /** Deploy the stack to the fresh anvil, then check what the app relies on. */ async function deploy() { const tool = await deployTool() @@ -93,10 +132,26 @@ async function deploy() { address: REGISTRY, abi: identityRegistryAbi, functionName: 'quoteBind', - args: [platformId('github'), 1], + args: [platformId('github'), VERIFIER_VERSION], }) if (quote !== 2_000_000_000_000_000n) - throw new Error(`quoteBind(github, 1) is ${quote}, expected 2e15`) + throw new Error(`quoteBind(github, ${VERIFIER_VERSION}) is ${quote}, expected 2e15`) + // Every platform this run binds has a verifier in the slot the app binds through. + const proofVerifier = await chain.readContract({ + address: REGISTRY, + abi: identityRegistryAbi, + functionName: 'proofVerifier', + }) + for (const platform of livePlatforms(process.env)) { + const verifier = await chain.readContract({ + address: proofVerifier, + abi: ceremonyProofVerifierAbi, + functionName: 'verifierOf', + args: [platformId(platform), VERIFIER_VERSION], + }) + if (BigInt(verifier) === 0n) + throw new Error(`No ${platform} verifier is registered at version ${VERIFIER_VERSION}`) + } } /** @@ -134,7 +189,9 @@ async function ready() { /** The chain's contracts, then Google's keys when this run binds Google. */ async function chainReady() { + if (stopping) return await deploy() + if (stopping) return if (livePlatforms(process.env).includes('google')) await rotateGoogleKeys() } @@ -145,9 +202,23 @@ execFileSync( ['--filter', '@libid/ceremony', 'build:ccdp-artifacts', '--out-dir', join(cache, 'ccdp')], { cwd: root, stdio: 'inherit' }, ) +// A stack this checkout left behind goes first, volumes and all, so every run +// starts from a fresh chain and database; a port still taken then belongs to +// another stack. +try { + await run('docker', [...composeArgs, 'down', '--volumes', '--remove-orphans']) +} catch { + console.error('Could not run Docker Compose. Install Docker with Compose and start its engine.') + process.exit(1) +} +try { + await portsFree() +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +} let frontend: ViteDevServer | undefined -let stopping = false function stop(code: number) { if (stopping) return stopping = true @@ -173,6 +244,7 @@ function stop(code: number) { } } const down = spawn('docker', [...composeArgs, 'down', '--volumes'], { stdio: 'inherit' }) + down.on('error', () => console.error('Could not stop the stack containers. Check Docker.')) down.on('close', (code) => { if (code !== 0) process.exitCode = 1 }) @@ -194,14 +266,21 @@ compose.on('exit', (code) => { try { await until('anvil', async () => (await chain.getChainId()) === CHAIN_ID) // Bridge and the indexer need neither the deploy nor the keeper. - await Promise.all([chainReady(), ready()]) - console.info(`Stack ready: chain ${RPC_URL}, registry ${REGISTRY}, indexer ${API}`) - if (process.argv.includes('--app') && !stopping) { - frontend = await createViteServer({ configFile: join(root, 'vite.config.ts') }) - await frontend.listen() - frontend.printUrls() + if (!stopping) await Promise.all([chainReady(), ready()]) + if (!stopping) { + console.info(`Stack ready: chain ${RPC_URL}, registry ${REGISTRY}, indexer ${API}`) + if (process.argv.includes('--app')) { + frontend = await createViteServer({ configFile: join(root, 'vite.config.ts') }) + if (stopping) await frontend.close() + else { + await frontend.listen() + frontend.printUrls() + } + } } } catch (error) { - console.error(error instanceof Error ? error.message : error) - stop(1) + if (!stopping) { + console.error(error instanceof Error ? error.message : error) + stop(1) + } } From 5b2b0a74fe66a5fc7f1830f12665363451cf02a9 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 17:45:09 +0300 Subject: [PATCH 17/24] fix(bind): finish the GitHub authorization on GitHub's redirect After GitHub's error page the driver went back a page and cleared its "login filled" flag, and leaving github.com counted only with that flag set. The session was already signed in, so no login form came to set it again, and the redirect was never taken for success; going back could also leave github.com without any redirect. As in libid-server-rs' github.rs, the error page now reopens the authorization request the run started on, so the popup leaves github.com only through GitHub's redirect, and leaving it is success. The popup driver loses its `done` hook, which only GitHub used. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/bind/e2e/github.ts | 21 +++++++++++++-------- ts/apps/bind/e2e/popup.ts | 4 +--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/ts/apps/bind/e2e/github.ts b/ts/apps/bind/e2e/github.ts index 5cf22547..78c92f50 100644 --- a/ts/apps/bind/e2e/github.ts +++ b/ts/apps/bind/e2e/github.ts @@ -29,6 +29,8 @@ function totp(secret: string): string { /** What the login has done so far; each handler reads and updates it. */ interface Progress { + /** The first GitHub page of the run: the authorization request, which a restart reopens. */ + start?: string filled: boolean totpStep?: number rateLimited: number @@ -39,7 +41,8 @@ async function onErrorPage(popup: Page, progress: Progress) { progress.errored += 1 if (progress.errored > 3) throw new Error('GitHub answered its error page three times') await sleep(10_000 * progress.errored) - await popup.goBack().catch(() => {}) + // Reopening the request keeps the popup on github.com until GitHub redirects. + if (progress.start) await popup.goto(progress.start).catch(() => {}) progress.filled = false progress.totpStep = undefined } @@ -86,9 +89,11 @@ async function onGitHubPage( popup: Page, account: GitHubAccount, progress: Progress, - path: string, + url: URL, text: string, ) { + progress.start ??= url.href + const path = url.pathname if ( text.includes("couldn't respond to your request in time") || text.includes('something went wrong') @@ -106,17 +111,17 @@ async function onGitHubPage( } /** - * Sign in and authorize on GitHub in `popup`, until it leaves github.com. A - * port of libid-server-rs' ceremony-tests/src/browser/github.rs: fill the login - * form once, answer the authenticator step once per 30-second code, approve the - * OAuth app, and back off on GitHub's error and rate-limit pages. + * Sign in and authorize on GitHub in `popup`, until GitHub redirects away from + * github.com. A port of libid-server-rs' ceremony-tests/src/browser/github.rs: + * fill the login form once, answer the authenticator step once per 30-second + * code, approve the OAuth app, back off on rate-limit pages, and start the + * authorization over on GitHub's error page. */ export async function authorizeOnGitHub(popup: Page, account: GitHubAccount) { const progress: Progress = { filled: false, rateLimited: 0, errored: 0 } await drivePopup(popup, { name: 'GitHub', onHost: (url) => url.hostname === 'github.com', - step: (url, text) => onGitHubPage(popup, account, progress, url.pathname, text), - done: () => progress.filled, + step: (url, text) => onGitHubPage(popup, account, progress, url, text), }) } diff --git a/ts/apps/bind/e2e/popup.ts b/ts/apps/bind/e2e/popup.ts index c34e5f17..663adaa7 100644 --- a/ts/apps/bind/e2e/popup.ts +++ b/ts/apps/bind/e2e/popup.ts @@ -12,8 +12,6 @@ export interface Driver { onHost: (url: URL) => boolean /** Act on one of the platform's pages; `text` is its body, lowercased. */ step: (url: URL, text: string) => Promise - /** Whether leaving the platform now means the authorization is done. */ - done?: () => boolean } /** @@ -35,7 +33,7 @@ export async function drivePopup(popup: Page, driver: Driver) { .catch(() => '')) ?? '' ).toLowerCase() await driver.step(url, text) - } else if (url && visited && (driver.done?.() ?? true)) { + } else if (url && visited) { return } await sleep(250) From 29510f442f742ef13c58abc59b12f97674b143cb Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 17:45:09 +0300 Subject: [PATCH 18/24] ci(bind): nest the live run's budgets, and skip runs without secrets - Dependabot's pull requests get no secrets; the job skips them as it skips forks. - Checkout keeps no credentials. - The test step has its own 45-minute timeout (stack start 15, two tests of 12, stop 2), inside a 55-minute job, so a hung run still reaches the secret scan and the upload. A test's timeout is 12 minutes: the popup driver's 3 and the page's 8-minute outcome wait. - The scan runs when the test step failed or timed out, not on a cancelled run, and the upload follows a clean scan. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 23 ++++++++++++++++++----- ts/apps/bind/playwright.live.config.ts | 3 ++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 80fcef11..9ed65121 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -28,15 +28,22 @@ permissions: jobs: bind: name: GitHub and X bindings (live) - # A pull request from a fork gets no secrets, so it does not run. - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + # A pull request from a fork or from Dependabot gets no secrets, so it + # does not run. + if: >- + github.event_name != 'pull_request' || + (github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]') runs-on: ubuntu-latest - timeout-minutes: 30 + # The test step's budget plus setup, the secret scan and the upload. + timeout-minutes: 55 env: # A missing secret fails the run instead of skipping the test. LIBID_REQUIRE_LIVE: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: ./.github/actions/setup-ts @@ -46,7 +53,12 @@ jobs: # Google is left out: it challenges a saved session arriving from a # runner's address ("Verify it's you"), which no automation passes. Its # test still runs locally, and the stack rotates no Google keys here. + # Its own timeout, so a run that hangs still reaches the steps below: + # the stack's 15-minute start, two tests of 12 minutes, and 2 minutes + # to stop the stack (playwright.live.config.ts), with room to spare. - name: Bind GitHub and X identities end to end + id: test + timeout-minutes: 45 env: LIBID_LIVE_PLATFORMS: github,x LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log @@ -60,9 +72,10 @@ jobs: # The artifact is public: only screenshots, Playwright's error notes and # the container log go up, and only when none of them holds a secret. + # A timed-out test step counts too; a cancelled run uploads nothing. - name: Check the failure output for secrets id: scan - if: failure() + if: ${{ !cancelled() && (steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} env: # A session that is not configured is nothing to look for. LIBID_REQUIRE_LIVE: "" @@ -72,7 +85,7 @@ jobs: run: node ts/apps/bind/e2e/scan-secrets.ts ts/apps/bind/test-results "${{ runner.temp }}/stack.log" - name: Keep the screenshots and container logs - if: failure() && steps.scan.outcome == 'success' + if: ${{ !cancelled() && steps.scan.outcome == 'success' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: live-e2e diff --git a/ts/apps/bind/playwright.live.config.ts b/ts/apps/bind/playwright.live.config.ts index 469b777f..c52a538f 100644 --- a/ts/apps/bind/playwright.live.config.ts +++ b/ts/apps/bind/playwright.live.config.ts @@ -18,7 +18,8 @@ export default defineConfig({ testDir: 'e2e', testMatch: '*.live.spec.ts', workers: 1, - timeout: 10 * 60_000, + // Each test: the popup driver's 3 minutes and the page's 8-minute outcome wait. + timeout: 12 * 60_000, reporter: [['list']], // One command: start the stack and the app, test, then stop both. A stack // already running locally is reused. From 69a2e72f0e5ae8ad46c13e7db81394ddf2d247f9 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 19:32:44 +0300 Subject: [PATCH 19/24] fix(bind): the popup returns on Bridge's callback, inside one budget - drivePopup succeeds only once the popup requests Bridge's OAuth callback (local.ts BRIDGE + /auth/callback), seen as a navigation request so an immediate redirect onward still counts. Leaving the platform for another page keeps it waiting until its budget names the page; a Chrome error page or a popup closed before the return fails at once. - Waits inside a driver go through `pause`, which rejects with the driver's own reason when the wait would pass the 180 s budget, so GitHub's rate-limit and error-page backoffs report themselves instead of a generic timeout. The budget and the page's 8-minute outcome wait fit the 12-minute test timeout. - The page text reaches every driver lowercased with typographic apostrophes as plain ones; Google's own copy of that goes. - GitHub's authorize click has a 5 s timeout, ignores a failed click as X and Google do, and is not repeated within 5 s; the two-factor goto and the rate-limit reload ignore their errors, as github.rs does. - Session renewal hints say where libid-server-rs' `ceremony` lives: branch feat/live-ceremony-tests, libid-server-rs PR #12. stealth.js names presentAsPerson, which installs it. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/bind/e2e/github.ts | 51 +++++++++++++++-------- ts/apps/bind/e2e/google.ts | 12 +++--- ts/apps/bind/e2e/popup.ts | 81 ++++++++++++++++++++++++++----------- ts/apps/bind/e2e/stealth.js | 7 ++-- ts/apps/bind/e2e/x.ts | 7 ++-- 5 files changed, 106 insertions(+), 52 deletions(-) diff --git a/ts/apps/bind/e2e/github.ts b/ts/apps/bind/e2e/github.ts index 78c92f50..6b55da1f 100644 --- a/ts/apps/bind/e2e/github.ts +++ b/ts/apps/bind/e2e/github.ts @@ -1,7 +1,7 @@ import type { Page } from '@playwright/test' import * as OTPAuth from 'otpauth' import { liveSecret } from './live.ts' -import { drivePopup, sleep } from './popup.ts' +import { drivePopup, type Pause } from './popup.ts' export interface GitHubAccount { username: string @@ -33,26 +33,37 @@ interface Progress { start?: string filled: boolean totpStep?: number + /** When the authorize button was last clicked; it is not clicked again within 5 s. */ + authorizedAt: number rateLimited: number errored: number } -async function onErrorPage(popup: Page, progress: Progress) { +async function onErrorPage(popup: Page, progress: Progress, pause: Pause) { progress.errored += 1 if (progress.errored > 3) throw new Error('GitHub answered its error page three times') - await sleep(10_000 * progress.errored) + await pause(10_000 * progress.errored, `GitHub answered its error page ${progress.errored} times`) // Reopening the request keeps the popup on github.com until GitHub redirects. if (progress.start) await popup.goto(progress.start).catch(() => {}) progress.filled = false progress.totpStep = undefined } -async function onLogin(popup: Page, account: GitHubAccount, progress: Progress, text: string) { +async function onLogin( + popup: Page, + account: GitHubAccount, + progress: Progress, + text: string, + pause: Pause, +) { if (text.includes('too many requests') || text.includes('rate limit')) { progress.rateLimited += 1 if (progress.rateLimited > 3) throw new Error('GitHub rate-limited the login three times') - await sleep(30_000 * progress.rateLimited) - await popup.reload() + await pause( + 30_000 * progress.rateLimited, + `GitHub rate-limited the login ${progress.rateLimited} times`, + ) + await popup.reload().catch(() => {}) progress.filled = false return } @@ -75,10 +86,14 @@ async function onTotp(popup: Page, account: GitHubAccount, progress: Progress) { progress.totpStep = step } -async function onAuthorize(popup: Page, text: string) { +async function onAuthorize(popup: Page, progress: Progress, text: string) { const authorize = popup.locator("button[name='authorize'][value='1']") - if (await authorize.count()) await authorize.click() - else if (text.includes('redirect_uri') || text.includes('be careful')) + if (await authorize.count()) { + if (Date.now() - progress.authorizedAt < 5_000) return + progress.authorizedAt = Date.now() + // The page may already be leaving for the redirect; the next poll sees where it went. + await authorize.click({ timeout: 5_000 }).catch(() => {}) + } else if (text.includes('redirect_uri') || text.includes('be careful')) throw new Error( "GitHub refused the authorization request: the app's callback URL does not match", ) @@ -91,6 +106,7 @@ async function onGitHubPage( progress: Progress, url: URL, text: string, + pause: Pause, ) { progress.start ??= url.href const path = url.pathname @@ -98,30 +114,31 @@ async function onGitHubPage( text.includes("couldn't respond to your request in time") || text.includes('something went wrong') ) - return onErrorPage(popup, progress) - if (path === '/login' || path === '/session') return onLogin(popup, account, progress, text) + return onErrorPage(popup, progress, pause) + if (path === '/login' || path === '/session') + return onLogin(popup, account, progress, text, pause) if (path === '/sessions/two-factor/app') return onTotp(popup, account, progress) if (path.startsWith('/sessions/two-factor')) { - await popup.goto('https://github.com/sessions/two-factor/app') + await popup.goto('https://github.com/sessions/two-factor/app').catch(() => {}) return } - if (path === '/login/oauth/authorize') return onAuthorize(popup, text) + if (path === '/login/oauth/authorize') return onAuthorize(popup, progress, text) if (path.startsWith('/sessions/verified-device')) throw new Error('GitHub asked for device verification: the account has no TOTP method') } /** - * Sign in and authorize on GitHub in `popup`, until GitHub redirects away from - * github.com. A port of libid-server-rs' ceremony-tests/src/browser/github.rs: + * Sign in and authorize on GitHub in `popup`, until GitHub redirects it to + * Bridge's callback. A port of libid-server-rs' ceremony-tests/src/browser/github.rs: * fill the login form once, answer the authenticator step once per 30-second * code, approve the OAuth app, back off on rate-limit pages, and start the * authorization over on GitHub's error page. */ export async function authorizeOnGitHub(popup: Page, account: GitHubAccount) { - const progress: Progress = { filled: false, rateLimited: 0, errored: 0 } + const progress: Progress = { filled: false, authorizedAt: 0, rateLimited: 0, errored: 0 } await drivePopup(popup, { name: 'GitHub', onHost: (url) => url.hostname === 'github.com', - step: (url, text) => onGitHubPage(popup, account, progress, url, text), + step: (url, text, pause) => onGitHubPage(popup, account, progress, url, text, pause), }) } diff --git a/ts/apps/bind/e2e/google.ts b/ts/apps/bind/e2e/google.ts index 74b8f694..263973a9 100644 --- a/ts/apps/bind/e2e/google.ts +++ b/ts/apps/bind/e2e/google.ts @@ -5,8 +5,9 @@ import { savedSession } from './session.ts' /** * The saved Google session, from GOOGLE_TEST_ALICE_COOKIES or * GOOGLE_TEST_ALICE_COOKIES_FILE. Renew it with `cargo run --bin ceremony -- - * export google` in libid-server-rs' ceremony-tests: Google refuses a sign-in - * in an automated browser, but honours a session made elsewhere. + * export google` in libid-server-rs' ceremony-tests, which lives on its + * feat/live-ceremony-tests branch (libid-server-rs PR #12): Google refuses a + * sign-in in an automated browser, but honours a session made elsewhere. */ export const googleSession = () => savedSession('GOOGLE_TEST_ALICE', ['SID', '__Secure-1PSID', '__Secure-3PSID']) @@ -41,7 +42,7 @@ const markNext = (email: string) => `(() => { /** * Choose the account and consent on Google in `popup`, signed in by the saved - * session, until it leaves Google. A port of libid-server-rs' + * session, until Google returns it to Bridge's callback. A port of libid-server-rs' * ceremony-tests/src/browser/google.rs without its sign-in: a login page means * the saved session has expired. */ @@ -50,15 +51,14 @@ export async function authorizeOnGoogle(popup: Page, email: string) { await drivePopup(popup, { name: 'Google', onHost: (url) => url.hostname === 'accounts.google.com', - step: async (url, body) => { - const text = body.replaceAll('’', "'") + step: async (url, text) => { const refused = REFUSED.find(([marker]) => text.includes(marker)) if (refused) throw new Error(`${refused[1]} (${url.pathname})`) if (url.pathname.includes('/challenge/')) throw new Error(`Google asks to verify the account interactively (${url.pathname})`) if (await popup.evaluate(LOGIN_VISIBLE).catch(() => false)) throw new Error( - 'Google asked to sign in: the saved session has expired. Renew it with `ceremony export google`.', + 'Google asked to sign in: the saved session has expired. Renew it with `ceremony export google` (libid-server-rs, branch feat/live-ceremony-tests).', ) if (Date.now() - clickedAt > 3_000 && (await clickMarked(popup, markNext(email)))) clickedAt = Date.now() diff --git a/ts/apps/bind/e2e/popup.ts b/ts/apps/bind/e2e/popup.ts index 663adaa7..860f0fd7 100644 --- a/ts/apps/bind/e2e/popup.ts +++ b/ts/apps/bind/e2e/popup.ts @@ -1,43 +1,78 @@ import { setTimeout as sleep } from 'node:timers/promises' import type { Page } from '@playwright/test' - -export { sleep } +import { BRIDGE } from '../local.ts' const BUDGET_MS = 180_000 +/** Where every platform returns the popup: Bridge's OAuth callback. */ +const CALLBACK = new URL('/auth/callback', BRIDGE) + +/** Wait `ms` inside the driver's budget; reject with `reason` if the wait would pass it. */ +export type Pause = (ms: number, reason: string) => Promise + export interface Driver { /** The platform's name, for the timeout message. */ name: string /** Whether `url` is one of the platform's own pages. */ onHost: (url: URL) => boolean - /** Act on one of the platform's pages; `text` is its body, lowercased. */ - step: (url: URL, text: string) => Promise + /** + * Act on one of the platform's pages; `text` is its body, lowercased, with + * typographic apostrophes as plain ones. + */ + step: (url: URL, text: string, pause: Pause) => Promise +} + +const isCallback = (url: string) => { + const parsed = URL.parse(url) + return parsed?.origin === CALLBACK.origin && parsed.pathname === CALLBACK.pathname } /** - * Drive `popup` through a platform's pages until it leaves them: the OAuth - * return, after which Callback and Prover take over. + * Drive `popup` through a platform's pages until the platform sends it to + * Bridge's callback, after which Callback and Prover take over. Leaving the + * platform for anywhere else is not a return: the driver keeps waiting, and + * fails on its budget naming the page it stopped on. */ export async function drivePopup(popup: Page, driver: Driver) { - const started = Date.now() - let visited = false - while (Date.now() - started < BUDGET_MS) { - if (popup.isClosed()) return - const url = URL.parse(popup.url()) - if (url && driver.onHost(url)) { - visited = true - const text = ( - (await popup - .locator('body') - .textContent() - .catch(() => '')) ?? '' - ).toLowerCase() - await driver.step(url, text) - } else if (url && visited) { - return + const deadline = Date.now() + BUDGET_MS + // A request, not a committed page: the callback may redirect on at once. + let returned = false + const onRequest = (request: { url(): string; isNavigationRequest(): boolean }) => { + if (request.isNavigationRequest() && isCallback(request.url())) returned = true + } + popup.on('request', onRequest) + const pause: Pause = async (ms, reason) => { + if (Date.now() + ms > deadline) + throw new Error( + `${reason}; waiting ${ms / 1000} s more would pass the ${BUDGET_MS / 1000} s budget`, + ) + await sleep(ms) + } + try { + while (Date.now() < deadline) { + if (returned) return + if (popup.isClosed()) + throw new Error(`The popup closed before ${driver.name} returned it to Bridge`) + const url = URL.parse(popup.url()) + if (url?.protocol === 'chrome-error:') + throw new Error(`The popup could not load a page during the ${driver.name} authorization`) + if (url && driver.onHost(url)) { + const text = ( + (await popup + .locator('body') + .textContent() + .catch(() => '')) ?? '' + ) + .toLowerCase() + .replaceAll('’', "'") + await driver.step(url, text, pause) + } + await sleep(250) } - await sleep(250) + } finally { + popup.off('request', onRequest) } + if (returned) return throw new Error( `No ${driver.name} authorization in ${BUDGET_MS / 1000} s; stopped on ${popup.url()}`, ) diff --git a/ts/apps/bind/e2e/stealth.js b/ts/apps/bind/e2e/stealth.js index 86e49ed5..3850f976 100644 --- a/ts/apps/bind/e2e/stealth.js +++ b/ts/apps/bind/e2e/stealth.js @@ -1,4 +1,5 @@ -// Ported from libid-server-rs ceremony-tests/src/browser/stealth.js. +// Ported from libid-server-rs ceremony-tests/src/browser/stealth.js, and +// installed in every page by presentAsPerson (person.ts). // Runs in every new document before the page's own scripts: what the page // can read is a person's Chrome on a Mac. @@ -12,8 +13,8 @@ Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }) Object.defineProperty(navigator, 'vendor', { get: () => 'Google Inc.' }) Object.defineProperty(navigator, 'platform', { get: () => 'MacIntel' }) -// The client hints are the user agent metadata `person::prepare` sets over -// CDP, which Chrome itself reports through `navigator.userAgentData` and the +// The client hints are the user agent metadata presentAsPerson (person.ts) +// sets over CDP, which Chrome itself reports through `navigator.userAgentData` and the // `sec-ch-ua` headers. // `window.chrome`, with the members a page reads off it. diff --git a/ts/apps/bind/e2e/x.ts b/ts/apps/bind/e2e/x.ts index f4f1336e..10e20073 100644 --- a/ts/apps/bind/e2e/x.ts +++ b/ts/apps/bind/e2e/x.ts @@ -5,7 +5,8 @@ import { restoreSession, type Stored, savedSession } from './session.ts' /** * The saved X session, from X_TEST_ALICE_COOKIES or X_TEST_ALICE_COOKIES_FILE. * Renew it with `cargo run --bin ceremony -- export x` in libid-server-rs' - * ceremony-tests. + * ceremony-tests, which lives on its feat/live-ceremony-tests branch + * (libid-server-rs PR #12). */ export const xSession = () => savedSession('X_TEST_ALICE', ['auth_token']) @@ -43,7 +44,7 @@ const onX = (url: URL) => /** * Approve the OAuth app on X in `popup`, signed in by the saved session, until - * it leaves X. A port of libid-server-rs' ceremony-tests/src/browser/x.rs + * X returns it to Bridge's callback. A port of libid-server-rs' ceremony-tests/src/browser/x.rs * without its interactive sign-in: a login page means the saved session has * expired. */ @@ -60,7 +61,7 @@ export async function authorizeOnX(popup: Page) { throw new Error('X security verification did not clear within 90 s') } else if (url.pathname.startsWith('/i/flow/login') || url.pathname === '/login') { throw new Error( - 'X asked to sign in: the saved session has expired. Renew it with `ceremony export x`.', + 'X asked to sign in: the saved session has expired. Renew it with `ceremony export x` (libid-server-rs, branch feat/live-ceremony-tests).', ) } else if (url.pathname.startsWith('/i/oauth2/authorize')) { challengedAt = undefined From 62363f5ccf9c8b0b726fd5ffec4d347b9da7f39a Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 19:32:44 +0300 Subject: [PATCH 20/24] fix(bind): report "bound" only for a binding the registry and indexer agree on - The outcome is "bound" only when the registry resolves the identity to the holder the run started with and the indexer reports that holder; any other holder, or no indexer answer within 60 s, is a failure with its own message. - A ceremony that fails to start sets the outcome too, so the test ends on its message instead of waiting out the outcome timeout. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/bind/src/app.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/ts/apps/bind/src/app.ts b/ts/apps/bind/src/app.ts index 3cd41536..05b8a341 100644 --- a/ts/apps/bind/src/app.ts +++ b/ts/apps/bind/src/app.ts @@ -18,6 +18,7 @@ import { getContract, hexToBytes, http, + isAddressEqual, type WalletClient, } from 'viem' import { privateKeyToAccount } from 'viem/accounts' @@ -138,14 +139,20 @@ async function submit( result.identity.userId, ) show('registry-holder', bound ?? 'not bound') + if (!bound || !isAddressEqual(bound, holder)) + throw new Error(`The registry binds the identity to ${bound ?? 'no one'}, not ${holder}`) status('Bound on chain. Waiting for the indexer…') - show('indexer-holder', await indexed(platform, result.identity.userId)) + const owner = await indexed(platform, result.identity.userId) + show('indexer-holder', owner ?? 'not indexed after 60 s') + if (!owner) throw new Error('The indexer did not report the binding within 60 s') + if (!isAddressEqual(owner as Address, holder)) + throw new Error(`The indexer reports ${owner} as the holder, not ${holder}`) status('Done.') show('outcome', 'bound') } -/** The holder the indexer reports for an id, polled until it appears. */ -async function indexed(platform: Platform, userId: string): Promise { +/** The holder the indexer reports for an id, polled until it appears; undefined after 60 s. */ +async function indexed(platform: Platform, userId: string): Promise { for (let attempt = 0; attempt < 60; attempt++) { const response = await fetch(`/indexer/v1/resolve/id/${platform}/${encodeURIComponent(userId)}`) if (response.ok) { @@ -155,7 +162,7 @@ async function indexed(platform: Platform, userId: string): Promise { } await new Promise((resolve) => setTimeout(resolve, 1000)) } - return 'not indexed after 60 s' + return undefined } /** Start a ceremony for `platform` from its anchor's click, then bind what it proves. */ @@ -179,7 +186,9 @@ function launch( } catch (error) { event.preventDefault() connection.close() - status(error instanceof Error ? error.message : 'Unable to start the ceremony.') + const message = error instanceof Error ? error.message : 'Unable to start the ceremony.' + status(message) + show('outcome', `failed: ${message}`) return } const off = ceremony.onStage((update) => { From ff1c242bbf50c07331ae9f7f66290e8848ffcf3f Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 19:32:44 +0300 Subject: [PATCH 21/24] fix(bind): one stack per checkout, its output kept off the job log - stack.ts takes .cache/stack.pid before touching the Compose project and refuses while another live stack.ts holds it, so a second run no longer tears down the first; a lock whose process is gone is stale. The lock also covers the CCDP build, which the running ccdp container mounts. - With LIBID_STACK_LOGS set, every child (the CCDP build, Compose with the containers' logs, libid-deploy, the keeper) writes to that file as it runs, not to our output, and the end-of-run `compose logs` goes. Under CI the Playwright web server drops the stack's stdout, so the public job log carries none of it. - Compose pulls every image, the keeper's included, before anything is timed, so a cold pull no longer eats the anvil wait. - libid-deploy is chosen by host (Linux or macOS, x86_64 or aarch64), each archive pinned by SHA-256, and a failed download reports its status instead of a digest mismatch. - Startup fails fast when local.ts, compose.yaml or keeper.toml disagree with local-dev.toml's [contracts] addresses, or bridge-config.toml does not admit local.ts' app origin. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/bind/local.ts | 3 +- ts/apps/bind/playwright.live.config.ts | 4 +- ts/apps/bind/stack.ts | 166 ++++++++++++++++++++----- 3 files changed, 142 insertions(+), 31 deletions(-) diff --git a/ts/apps/bind/local.ts b/ts/apps/bind/local.ts index abb2dba7..3f1ab37a 100644 --- a/ts/apps/bind/local.ts +++ b/ts/apps/bind/local.ts @@ -1,5 +1,6 @@ // The local stack's addresses and ports, for the stack, the app and the tests. -// compose.yaml, keeper.toml and bridge-config.toml repeat what they need. +// compose.yaml, keeper.toml and bridge-config.toml repeat what they need, and +// stack.ts refuses to start when a copy differs. /** anvil, published on the host. */ export const RPC_URL = 'http://127.0.0.1:4688' diff --git a/ts/apps/bind/playwright.live.config.ts b/ts/apps/bind/playwright.live.config.ts index c52a538f..e46afbc9 100644 --- a/ts/apps/bind/playwright.live.config.ts +++ b/ts/apps/bind/playwright.live.config.ts @@ -29,7 +29,9 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, timeout: 15 * 60_000, gracefulShutdown: { signal: 'SIGTERM', timeout: 120_000 }, - stdout: 'pipe', + // CI keeps the stack's output off the public job log: the stack writes + // it to LIBID_STACK_LOGS, which is scanned before upload. + stdout: process.env.CI ? 'ignore' : 'pipe', }, use: { ...devices['Desktop Chrome'], diff --git a/ts/apps/bind/stack.ts b/ts/apps/bind/stack.ts index 8d9d54e9..a68ac232 100644 --- a/ts/apps/bind/stack.ts +++ b/ts/apps/bind/stack.ts @@ -1,9 +1,12 @@ // Start a local chain with the real libID contracts and, when this run binds // Google (LIBID_LIVE_PLATFORMS), Google's keys rotated in; the notary, Bridge, // CCDP and the indexer; with --app, also the bind application. No OAuth mocks. +// +// LIBID_STACK_LOGS names a file that takes every child's output, the +// containers' logs included, as it is written; without it they share ours. import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { connect } from 'node:net' import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' @@ -37,17 +40,29 @@ const composeArgs = ['compose', '-p', project, '-f', join(root, 'compose.yaml')] const BRIDGE = 'http://127.0.0.1:4682' const chain = createPublicClient({ transport: http(RPC_URL) }) +const logs = process.env.LIBID_STACK_LOGS +/** Where children write: the LIBID_STACK_LOGS file, or our own output. */ +const childOutput = logs + ? (() => { + const fd = openSync(logs, 'w') + return ['ignore', fd, fd] as ['ignore', number, number] + })() + : 'inherit' + /** chain-configurations' deploy tool; it deploys the real verifiers, not stubs. */ -const DEPLOY = { - version: '0.15.0', - asset: 'libid-deploy-0.15.0-x86_64-unknown-linux-gnu', - sha256: 'dc1cc678d861bf91494bcf827bd321904b6c3d5855df9821eafae2afd77914a6', +const DEPLOY_VERSION = '0.15.0' +/** Each release archive's SHA-256, by Rust target. */ +const DEPLOY_ARCHIVES: Record = { + 'x86_64-unknown-linux-gnu': 'dc1cc678d861bf91494bcf827bd321904b6c3d5855df9821eafae2afd77914a6', + 'aarch64-unknown-linux-gnu': '7da9609d6a9f77b8d3699dadb7ed7b75a5df98ec473a59eeb1b7aff2a2c4b4b3', + 'x86_64-apple-darwin': '43e4d39ba6acc84289d843dd36e7ca271348a5a4c4bfb3653b907f0f5e94e6a9', + 'aarch64-apple-darwin': '042c444679473fa83ec8e089ea79d14df29f13c3a13e67b2c986524bf07e057e', } -/** Run a command to completion, its output on ours; reject on a nonzero exit. */ +/** Run a command to completion; reject on a nonzero exit. */ function run(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { - const child = spawn(command, args, { stdio: 'inherit' }) + const child = spawn(command, args, { stdio: childOutput }) child.on('error', reject) child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`${command} ${args[0]} exited with ${code}`)), @@ -72,16 +87,26 @@ async function until(what: string, check: () => Promise, seconds = 120) throw new Error(`Timed out waiting for ${what}`) } +/** This host's libid-deploy, downloaded and checked against its pinned digest once. */ async function deployTool(): Promise { - const binary = join(cache, DEPLOY.asset, 'libid-deploy') + const arch = { x64: 'x86_64', arm64: 'aarch64' }[process.arch as string] + const os = { linux: 'unknown-linux-gnu', darwin: 'apple-darwin' }[process.platform as string] + const target = `${arch}-${os}` + const sha256 = DEPLOY_ARCHIVES[target] + if (!arch || !os || !sha256) + throw new Error(`libid-deploy has no release for ${process.platform} ${process.arch}`) + const asset = `libid-deploy-${DEPLOY_VERSION}-${target}` + const binary = join(cache, asset, 'libid-deploy') if (existsSync(binary)) return binary mkdirSync(cache, { recursive: true }) - const url = `https://github.com/libid-org/chain-configurations/releases/download/v${DEPLOY.version}/${DEPLOY.asset}.tar.gz` - const archive = Buffer.from(await (await fetch(url)).arrayBuffer()) + const url = `https://github.com/libid-org/chain-configurations/releases/download/v${DEPLOY_VERSION}/${asset}.tar.gz` + const response = await fetch(url) + if (!response.ok) throw new Error(`Downloading ${url} answered ${response.status}`) + const archive = Buffer.from(await response.arrayBuffer()) const digest = createHash('sha256').update(archive).digest('hex') - if (digest !== DEPLOY.sha256) + if (digest !== sha256) throw new Error(`libid-deploy archive digest ${digest} is not the pinned one`) - const path = join(cache, `${DEPLOY.asset}.tar.gz`) + const path = join(cache, `${asset}.tar.gz`) writeFileSync(path, archive) execFileSync('tar', ['xzf', path, '-C', cache]) return binary @@ -114,6 +139,90 @@ async function portsFree() { ) } +/** The quoted value of `key` in `text`, a TOML or YAML file's line `key = "…"` or `key: "…"`. */ +function quoted(text: string, key: string, file: string): string { + const value = new RegExp(`^\\s*${key}\\s*[=:]\\s*"([^"]*)"`, 'm').exec(text)?.[1] + if (value === undefined) throw new Error(`${file} has no ${key}`) + return value +} + +/** + * Fail fast when a copy of local-dev.toml's addresses has drifted: local.ts, + * compose.yaml and keeper.toml repeat them, and bridge-config.toml admits + * local.ts' app origin. + */ +function configAgrees() { + const read = (file: string) => readFileSync(join(root, file), 'utf8') + const network = read('local-dev.toml') + const contracts = network.slice(network.indexOf('\n[contracts]')) + const registry = quoted(contracts, 'identity_registry', 'local-dev.toml [contracts]') + const roots = quoted(contracts, 'google_jwt_roots', 'local-dev.toml [contracts]') + const copies: [string, string, string][] = [ + ['local.ts REGISTRY', REGISTRY, registry], + ['local.ts GOOGLE_JWT_ROOTS', GOOGLE_JWT_ROOTS, roots], + [ + 'compose.yaml IDENTITY_NAMES_ADDRESS', + quoted(read('compose.yaml'), 'IDENTITY_NAMES_ADDRESS', 'compose.yaml'), + registry, + ], + [ + 'keeper.toml google_jwt_roots', + quoted(read('keeper.toml'), 'google_jwt_roots', 'keeper.toml'), + roots, + ], + ] + const drifted = copies.filter(([, copy, source]) => copy.toLowerCase() !== source.toLowerCase()) + const bridge = read('bridge-config.toml') + if (!bridge.includes(`"${APP_ORIGIN}"`)) + drifted.push(['bridge-config.toml allowed_app_origins', '', APP_ORIGIN]) + if (drifted.length) + throw new Error( + `Out of step with local-dev.toml or local.ts: ${drifted.map(([name, , source]) => `${name} should be ${source}`).join('; ')}`, + ) +} + +/** This checkout's stack lock: the pid of the stack.ts that owns the Compose project. */ +const lock = join(cache, 'stack.pid') + +const alive = (pid: number) => { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +/** + * Own this checkout's Compose project, or refuse: a second stack.ts would + * tear down the first one's containers. A lock whose process is gone is stale. + */ +function takeLock() { + mkdirSync(cache, { recursive: true }) + for (let attempt = 0; attempt < 2; attempt++) { + try { + writeFileSync(lock, String(process.pid), { flag: 'wx' }) + process.on('exit', releaseLock) + return + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } + const owner = Number(readFileSync(lock, 'utf8')) + if (owner && alive(owner)) + throw new Error(`stack.ts (pid ${owner}) already runs this checkout's stack. Stop it first.`) + rmSync(lock, { force: true }) + } + throw new Error(`Could not take ${lock}`) +} + +function releaseLock() { + try { + if (readFileSync(lock, 'utf8') === String(process.pid)) rmSync(lock) + } catch { + /* already gone */ + } +} + /** Deploy the stack to the fresh anvil, then check what the app relies on. */ async function deploy() { const tool = await deployTool() @@ -197,18 +306,28 @@ async function chainReady() { // ─── Main ─────────────────────────────────────────────────────── +try { + configAgrees() + takeLock() +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +} execFileSync( 'pnpm', ['--filter', '@libid/ceremony', 'build:ccdp-artifacts', '--out-dir', join(cache, 'ccdp')], - { cwd: root, stdio: 'inherit' }, + { cwd: root, stdio: childOutput }, ) // A stack this checkout left behind goes first, volumes and all, so every run // starts from a fresh chain and database; a port still taken then belongs to -// another stack. +// another stack. The images come down before anything is timed. try { await run('docker', [...composeArgs, 'down', '--volumes', '--remove-orphans']) + await run('docker', [...composeArgs, '--profile', 'keeper', 'pull', '--quiet']) } catch { - console.error('Could not run Docker Compose. Install Docker with Compose and start its engine.') + console.error( + 'Could not run Docker Compose or pull its images. Install Docker with Compose and start its engine.', + ) process.exit(1) } try { @@ -219,6 +338,7 @@ try { } let frontend: ViteDevServer | undefined +/** Stop what this process started: the frontend, then its own Compose project. */ function stop(code: number) { if (stopping) return stopping = true @@ -231,19 +351,7 @@ function stop(code: number) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error } } - // LIBID_STACK_LOGS keeps every container's log, for a failed CI run to upload. - const logs = process.env.LIBID_STACK_LOGS - if (logs) { - try { - writeFileSync( - logs, - execFileSync('docker', [...composeArgs, 'logs', '--no-color'], { maxBuffer: 1 << 28 }), - ) - } catch { - console.error('Could not save the container logs.') - } - } - const down = spawn('docker', [...composeArgs, 'down', '--volumes'], { stdio: 'inherit' }) + const down = spawn('docker', [...composeArgs, 'down', '--volumes'], { stdio: childOutput }) down.on('error', () => console.error('Could not stop the stack containers. Check Docker.')) down.on('close', (code) => { if (code !== 0) process.exitCode = 1 @@ -252,7 +360,7 @@ function stop(code: number) { process.once('SIGINT', () => stop(0)) process.once('SIGTERM', () => stop(0)) const compose = spawn('docker', [...composeArgs, 'up', '--abort-on-container-failure'], { - stdio: 'inherit', + stdio: childOutput, detached: true, }) compose.on('error', () => { From 86166bf31b77d31494914d905f8118e0b38134dc Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 19:32:44 +0300 Subject: [PATCH 22/24] ci(bind): per-PR concurrency, and scan the log the run finished with - Each pull request has its own concurrency group, a newer push cancelling the older run; scheduled and dispatched runs share another. A single group let one PR's pending run cancel another's. - Before the scan, any libid-bind Compose project still up is taken down, and the scan and the upload use one copy of the stack log, so nothing written after the scan goes up. - The artifact comment says what the scan does: it reads bytes, so a screenshot is safe because the pages driven never display a secret, and the job log carries none of the stack's output. - The X session comment says where `ceremony export x` lives. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 9ed65121..bc5362f4 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -17,10 +17,13 @@ on: schedule: - cron: "23 3 * * *" -# One run at a time: runs share the platforms' test accounts. +# One run per pull request, a newer push replacing an older run; scheduled +# and dispatched runs queue in a group of their own. Runs of different groups +# overlap on the platforms' test accounts, which hold concurrent sign-ins: +# each run binds on its own chain, and a rate-limited login backs off. concurrency: - group: live-e2e - cancel-in-progress: false + group: live-e2e-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -66,12 +69,18 @@ jobs: GH_TEST_ALICE_PASSWORD: ${{ secrets.GH_TEST_ALICE_PASSWORD }} GH_TEST_ALICE_TOTP_SECRET: ${{ secrets.GH_TEST_ALICE_TOTP_SECRET }} X_TEST_ALICE_USERNAME: ${{ secrets.X_TEST_ALICE_USERNAME }} - # The saved X session, base64 of libid-server-rs' `ceremony export x`. + # The saved X session, base64 of `ceremony export x` from + # libid-server-rs' ceremony-tests (branch feat/live-ceremony-tests, + # libid-server-rs PR #12). X_TEST_ALICE_COOKIES: ${{ secrets.X_TEST_ALICE_COOKIES }} run: pnpm -C ts/apps/bind test:live - # The artifact is public: only screenshots, Playwright's error notes and - # the container log go up, and only when none of them holds a secret. + # The artifact is public. Screenshots, Playwright's error notes and the + # stack's log go up only when the scan finds none of the test accounts' + # secrets in them. The scan reads bytes, not pixels: a screenshot shows + # what the page showed, and the pages driven never display the password, + # the TOTP secret or a session cookie. The job log carries none of the + # stack's output (playwright.live.config.ts). # A timed-out test step counts too; a cancelled run uploads nothing. - name: Check the failure output for secrets id: scan @@ -82,7 +91,13 @@ jobs: GH_TEST_ALICE_PASSWORD: ${{ secrets.GH_TEST_ALICE_PASSWORD }} GH_TEST_ALICE_TOTP_SECRET: ${{ secrets.GH_TEST_ALICE_TOTP_SECRET }} X_TEST_ALICE_COOKIES: ${{ secrets.X_TEST_ALICE_COOKIES }} - run: node ts/apps/bind/e2e/scan-secrets.ts ts/apps/bind/test-results "${{ runner.temp }}/stack.log" + # A stack the test step left running stops first, so nothing writes to + # the log after it is scanned; what goes up is the copy scanned. + run: | + docker compose ls -q --all --filter name=libid-bind | + xargs -r -I{} docker compose -p {} down --volumes + if [ -f "$RUNNER_TEMP/stack.log" ]; then cp "$RUNNER_TEMP/stack.log" "$RUNNER_TEMP/stack-scanned.log"; fi + node ts/apps/bind/e2e/scan-secrets.ts ts/apps/bind/test-results "$RUNNER_TEMP/stack-scanned.log" - name: Keep the screenshots and container logs if: ${{ !cancelled() && steps.scan.outcome == 'success' }} @@ -92,6 +107,6 @@ jobs: path: | ts/apps/bind/test-results/**/*.png ts/apps/bind/test-results/**/*.md - ${{ runner.temp }}/stack.log + ${{ runner.temp }}/stack-scanned.log retention-days: 14 if-no-files-found: ignore From ececb6d073e504a9ce2edb113d4fe7260b6780e4 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 19:54:46 +0300 Subject: [PATCH 23/24] refactor(bind): one stop signal for the stack, and overlap its startup An AbortController replaces the stopping flag: waits end on it and commands the stack runs are terminated by it, so the startup sequence reads straight through instead of re-checking a flag between steps. The deploy tool downloads and CCDP builds while Docker clears the old stack and pulls, with the clean-up and the pull side by side. One helper probes Bridge and the indexer API, the ports are probed at once, the Bridge address comes from local.ts, the archive is unpacked from memory, and one fail() reports a refused start. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/bind/stack.ts | 155 +++++++++++++++++++++++------------------- 1 file changed, 85 insertions(+), 70 deletions(-) diff --git a/ts/apps/bind/stack.ts b/ts/apps/bind/stack.ts index a68ac232..db0a5ff3 100644 --- a/ts/apps/bind/stack.ts +++ b/ts/apps/bind/stack.ts @@ -22,6 +22,7 @@ import { createServer as createViteServer, type ViteDevServer } from 'vite' import { API, APP_ORIGIN, + BRIDGE, CHAIN_ID, GOOGLE_JWT_ROOTS, livePlatforms, @@ -37,8 +38,9 @@ const project = `libid-bind-${createHash('sha256').update(root).digest('hex').sl const composeArgs = ['compose', '-p', project, '-f', join(root, 'compose.yaml')] // Docker publishes on 127.0.0.1; the browser reaches Bridge as localhost. -const BRIDGE = 'http://127.0.0.1:4682' +const BRIDGE_ON_HOST = BRIDGE.replace('localhost', '127.0.0.1') const chain = createPublicClient({ transport: http(RPC_URL) }) +const platforms = livePlatforms(process.env) const logs = process.env.LIBID_STACK_LOGS /** Where children write: the LIBID_STACK_LOGS file, or our own output. */ @@ -59,10 +61,14 @@ const DEPLOY_ARCHIVES: Record = { 'aarch64-apple-darwin': '042c444679473fa83ec8e089ea79d14df29f13c3a13e67b2c986524bf07e057e', } -/** Run a command to completion; reject on a nonzero exit. */ +/** Aborted once the stack stops: waits end and running commands are terminated. */ +const stopping = new AbortController() +const { signal } = stopping + +/** Run a command to completion; reject on a nonzero exit or once the stack stops. */ function run(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { - const child = spawn(command, args, { stdio: childOutput }) + const child = spawn(command, args, { cwd: root, stdio: childOutput, signal }) child.on('error', reject) child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`${command} ${args[0]} exited with ${code}`)), @@ -70,23 +76,28 @@ function run(command: string, args: string[]): Promise { }) } -let stopping = false - -/** Poll `check` until it holds; reject on the deadline or once the stack is stopping. */ +/** Poll `check` until it holds; reject on the deadline or once the stack stops. */ async function until(what: string, check: () => Promise, seconds = 120) { const deadline = Date.now() + seconds * 1000 while (Date.now() < deadline) { - if (stopping) throw new Error(`Stopped while waiting for ${what}`) + signal.throwIfAborted() try { if (await check()) return } catch { /* not up yet */ } - await delay(500) + await delay(500, undefined, { signal }) } throw new Error(`Timed out waiting for ${what}`) } +/** Whether `url` answers 200 within a second. */ +async function answers(url: string, init: RequestInit = {}): Promise { + const response = await fetch(url, { ...init, signal: AbortSignal.timeout(1000) }) + await response.body?.cancel() + return response.status === 200 +} + /** This host's libid-deploy, downloaded and checked against its pinned digest once. */ async function deployTool(): Promise { const arch = { x64: 'x86_64', arm64: 'aarch64' }[process.arch as string] @@ -106,9 +117,7 @@ async function deployTool(): Promise { const digest = createHash('sha256').update(archive).digest('hex') if (digest !== sha256) throw new Error(`libid-deploy archive digest ${digest} is not the pinned one`) - const path = join(cache, `${asset}.tar.gz`) - writeFileSync(path, archive) - execFileSync('tar', ['xzf', path, '-C', cache]) + execFileSync('tar', ['xz', '-C', cache], { input: archive }) return binary } @@ -131,8 +140,8 @@ function listening(port: number): Promise { */ async function portsFree() { const ports = [RPC_URL, NOTARY, BRIDGE, API].map((url) => Number(new URL(url).port)) - const taken = [] - for (const port of ports) if (await listening(port)) taken.push(port) + const open = await Promise.all(ports.map(listening)) + const taken = ports.filter((_, index) => open[index]) if (taken.length) throw new Error( `Already in use on 127.0.0.1: port ${taken.join(', ')}. Stop the stack holding it (see docker ps).`, @@ -223,10 +232,9 @@ function releaseLock() { } } -/** Deploy the stack to the fresh anvil, then check what the app relies on. */ -async function deploy() { - const tool = await deployTool() - await run(tool, [ +/** Deploy the stack to the fresh anvil with `tool`, then check what the app relies on. */ +async function deploy(tool: Promise) { + await run(await tool, [ 'apply', '--network', join(root, 'local-dev.toml'), @@ -251,7 +259,7 @@ async function deploy() { abi: identityRegistryAbi, functionName: 'proofVerifier', }) - for (const platform of livePlatforms(process.env)) { + for (const platform of platforms) { const verifier = await chain.readContract({ address: proofVerifier, abi: ceremonyProofVerifierAbi, @@ -278,30 +286,35 @@ async function rotateGoogleKeys() { if (stale) throw new Error('GoogleJwtRoots still needs a rotation after the keeper ran') } +/** Bridge and the indexer API, which need neither the deploy nor the keeper. */ async function ready() { - const bridge = until('Bridge', async () => { - const response = await fetch(`${BRIDGE}/api/v1/ceremony/config`, { - headers: { Origin: APP_ORIGIN }, - redirect: 'error', - signal: AbortSignal.timeout(1000), - }) - await response.body?.cancel() - return response.status === 200 - }) - const api = until('indexer API', async () => { - const response = await fetch(`${API}/v1/status`, { signal: AbortSignal.timeout(1000) }) - await response.body?.cancel() - return response.status === 200 - }) - await Promise.all([bridge, api]) + await Promise.all([ + until('Bridge', () => + answers(`${BRIDGE_ON_HOST}/api/v1/ceremony/config`, { + headers: { Origin: APP_ORIGIN }, + redirect: 'error', + }), + ), + until('indexer API', () => answers(`${API}/v1/status`)), + ]) } /** The chain's contracts, then Google's keys when this run binds Google. */ -async function chainReady() { - if (stopping) return - await deploy() - if (stopping) return - if (livePlatforms(process.env).includes('google')) await rotateGoogleKeys() +async function chainReady(tool: Promise) { + await deploy(tool) + if (platforms.includes('google')) await rotateGoogleKeys() +} + +/** Report `error` and exit before the stack has started. */ +function fail(error: unknown): never { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +} + +/** `promise`, marked handled: its rejection reaches whoever awaits it later instead of ending the process first. */ +function started(promise: Promise): Promise { + promise.catch(() => {}) + return promise } // ─── Main ─────────────────────────────────────────────────────── @@ -310,38 +323,44 @@ try { configAgrees() takeLock() } catch (error) { - console.error(error instanceof Error ? error.message : error) - process.exit(1) + fail(error) } -execFileSync( - 'pnpm', - ['--filter', '@libid/ceremony', 'build:ccdp-artifacts', '--out-dir', join(cache, 'ccdp')], - { cwd: root, stdio: childOutput }, +// The deploy tool downloads and CCDP builds while Docker clears a stack this +// checkout left behind, volumes and all, so every run starts from a fresh +// chain and database; a port still taken then belongs to another stack. The +// images come down before anything is timed. +const tool = started(deployTool()) +const ccdp = started( + run('pnpm', [ + '--filter', + '@libid/ceremony', + 'build:ccdp-artifacts', + '--out-dir', + join(cache, 'ccdp'), + ]), ) -// A stack this checkout left behind goes first, volumes and all, so every run -// starts from a fresh chain and database; a port still taken then belongs to -// another stack. The images come down before anything is timed. try { - await run('docker', [...composeArgs, 'down', '--volumes', '--remove-orphans']) - await run('docker', [...composeArgs, '--profile', 'keeper', 'pull', '--quiet']) + await Promise.all([ + run('docker', [...composeArgs, 'down', '--volumes', '--remove-orphans']), + run('docker', [...composeArgs, '--profile', 'keeper', 'pull', '--quiet']), + ]) } catch { - console.error( + fail( 'Could not run Docker Compose or pull its images. Install Docker with Compose and start its engine.', ) - process.exit(1) } try { + await ccdp await portsFree() } catch (error) { - console.error(error instanceof Error ? error.message : error) - process.exit(1) + fail(error) } let frontend: ViteDevServer | undefined /** Stop what this process started: the frontend, then its own Compose project. */ function stop(code: number) { - if (stopping) return - stopping = true + if (signal.aborted) return + stopping.abort() process.exitCode = code void frontend?.close() if (compose.pid) { @@ -367,27 +386,23 @@ compose.on('error', () => { console.error('Could not start Docker Compose. Install Docker with Compose and start its engine.') stop(1) }) -compose.on('exit', (code) => { - if (!stopping) stop(code || 1) -}) +compose.on('exit', (code) => stop(code || 1)) try { await until('anvil', async () => (await chain.getChainId()) === CHAIN_ID) - // Bridge and the indexer need neither the deploy nor the keeper. - if (!stopping) await Promise.all([chainReady(), ready()]) - if (!stopping) { - console.info(`Stack ready: chain ${RPC_URL}, registry ${REGISTRY}, indexer ${API}`) - if (process.argv.includes('--app')) { - frontend = await createViteServer({ configFile: join(root, 'vite.config.ts') }) - if (stopping) await frontend.close() - else { - await frontend.listen() - frontend.printUrls() - } + await Promise.all([chainReady(tool), ready()]) + signal.throwIfAborted() + console.info(`Stack ready: chain ${RPC_URL}, registry ${REGISTRY}, indexer ${API}`) + if (process.argv.includes('--app')) { + frontend = await createViteServer({ configFile: join(root, 'vite.config.ts') }) + if (signal.aborted) await frontend.close() + else { + await frontend.listen() + frontend.printUrls() } } } catch (error) { - if (!stopping) { + if (!signal.aborted) { console.error(error instanceof Error ? error.message : error) stop(1) } From fce31326cd9ec106229823bde63c0e14d436d98b Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 19:54:46 +0300 Subject: [PATCH 24/24] refactor(bind): share saved sessions and click marking across drivers The X and Google saved sessions live in session.ts, where the secret scan reads the same definitions the tests restore. clickFound owns the data-libid-click marking, so each driver gives only the control to find. GitHub's error-page and rate-limit back-offs share one helper, SameSite maps through a table, and the app reports a failed run through one fail(). Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/bind/e2e/bind.live.spec.ts | 6 ++--- ts/apps/bind/e2e/github.ts | 24 ++++++++++++-------- ts/apps/bind/e2e/google.ts | 25 +++++---------------- ts/apps/bind/e2e/popup.ts | 18 ++++++++++----- ts/apps/bind/e2e/scan-secrets.ts | 7 ++---- ts/apps/bind/e2e/session.ts | 36 +++++++++++++++++++++--------- ts/apps/bind/e2e/x.ts | 27 ++++++---------------- ts/apps/bind/src/app.ts | 27 +++++++++++----------- 8 files changed, 82 insertions(+), 88 deletions(-) diff --git a/ts/apps/bind/e2e/bind.live.spec.ts b/ts/apps/bind/e2e/bind.live.spec.ts index a12a1bdb..13e4ea2c 100644 --- a/ts/apps/bind/e2e/bind.live.spec.ts +++ b/ts/apps/bind/e2e/bind.live.spec.ts @@ -1,12 +1,12 @@ import { type BrowserContext, expect, type Page, test } from '@playwright/test' import { RPC_URL } from '../local.ts' import { authorizeOnGitHub, githubAccount } from './github.ts' -import { authorizeOnGoogle, googleSession } from './google.ts' +import { authorizeOnGoogle } from './google.ts' import { isLive, liveSecret } from './live.ts' import { presentAsPerson } from './person.ts' -import { restoreSession } from './session.ts' +import { googleSession, restoreSession, xSession } from './session.ts' import { injectWallet } from './wallet.ts' -import { authorizeOnX, restoreXSession, xSession } from './x.ts' +import { authorizeOnX, restoreXSession } from './x.ts' /** anvil account #2: unlocked by anvil, funded, and not one the deploy uses. */ const HOLDER = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' diff --git a/ts/apps/bind/e2e/github.ts b/ts/apps/bind/e2e/github.ts index 6b55da1f..4e3832e5 100644 --- a/ts/apps/bind/e2e/github.ts +++ b/ts/apps/bind/e2e/github.ts @@ -39,10 +39,21 @@ interface Progress { errored: number } +/** Count one more `what` under `counter`; wait `step` times the count, failing on the fourth. */ +async function backOff( + progress: Progress, + counter: 'rateLimited' | 'errored', + step: number, + what: string, + pause: Pause, +) { + const count = ++progress[counter] + if (count > 3) throw new Error(`${what} three times`) + await pause(step * count, `${what} ${count} times`) +} + async function onErrorPage(popup: Page, progress: Progress, pause: Pause) { - progress.errored += 1 - if (progress.errored > 3) throw new Error('GitHub answered its error page three times') - await pause(10_000 * progress.errored, `GitHub answered its error page ${progress.errored} times`) + await backOff(progress, 'errored', 10_000, 'GitHub answered its error page', pause) // Reopening the request keeps the popup on github.com until GitHub redirects. if (progress.start) await popup.goto(progress.start).catch(() => {}) progress.filled = false @@ -57,12 +68,7 @@ async function onLogin( pause: Pause, ) { if (text.includes('too many requests') || text.includes('rate limit')) { - progress.rateLimited += 1 - if (progress.rateLimited > 3) throw new Error('GitHub rate-limited the login three times') - await pause( - 30_000 * progress.rateLimited, - `GitHub rate-limited the login ${progress.rateLimited} times`, - ) + await backOff(progress, 'rateLimited', 30_000, 'GitHub rate-limited the login', pause) await popup.reload().catch(() => {}) progress.filled = false return diff --git a/ts/apps/bind/e2e/google.ts b/ts/apps/bind/e2e/google.ts index 263973a9..ebf8b285 100644 --- a/ts/apps/bind/e2e/google.ts +++ b/ts/apps/bind/e2e/google.ts @@ -1,16 +1,5 @@ import type { Page } from '@playwright/test' -import { clickMarked, drivePopup } from './popup.ts' -import { savedSession } from './session.ts' - -/** - * The saved Google session, from GOOGLE_TEST_ALICE_COOKIES or - * GOOGLE_TEST_ALICE_COOKIES_FILE. Renew it with `cargo run --bin ceremony -- - * export google` in libid-server-rs' ceremony-tests, which lives on its - * feat/live-ceremony-tests branch (libid-server-rs PR #12): Google refuses a - * sign-in in an automated browser, but honours a session made elsewhere. - */ -export const googleSession = () => - savedSession('GOOGLE_TEST_ALICE', ['SID', '__Secure-1PSID', '__Secure-3PSID']) +import { clickFound, drivePopup } from './popup.ts' /** Pages that no automation gets past: the session or the OAuth client needs a person. */ const REFUSED: [string, string][] = [ @@ -28,16 +17,12 @@ const REFUSED: [string, string][] = [ const LOGIN_VISIBLE = `[...document.querySelectorAll('input[type=password],input[type=email],input[name=identifier]')] .some(e => e.getClientRects().length && getComputedStyle(e).visibility !== 'hidden')` -/** The account row for `email`, else a Continue or Allow button, marked for a real click. */ -const markNext = (email: string) => `(() => { - document.querySelectorAll('[data-libid-click]').forEach(e => e.removeAttribute('data-libid-click')); +/** The account row for `email`, else a Continue or Allow button. */ +const findNext = (email: string) => `(() => { const usable = e => !e.disabled && e.getAttribute('aria-disabled') !== 'true' && e.getClientRects().length && getComputedStyle(e).visibility !== 'hidden'; const account = [...document.querySelectorAll('[data-identifier]')].find(e => usable(e) && e.getAttribute('data-identifier').toLowerCase() === ${JSON.stringify(email.toLowerCase())}); const consent = [...document.querySelectorAll('button,[role=button],input[type=submit]')].find(e => usable(e) && /^(continue|allow)$/i.test((e.innerText || e.value || '').trim())); - const next = account || consent; - if (!next) return false; - next.setAttribute('data-libid-click', '1'); - return true; + return account || consent; })()` /** @@ -60,7 +45,7 @@ export async function authorizeOnGoogle(popup: Page, email: string) { throw new Error( 'Google asked to sign in: the saved session has expired. Renew it with `ceremony export google` (libid-server-rs, branch feat/live-ceremony-tests).', ) - if (Date.now() - clickedAt > 3_000 && (await clickMarked(popup, markNext(email)))) + if (Date.now() - clickedAt > 3_000 && (await clickFound(popup, findNext(email)))) clickedAt = Date.now() }, }) diff --git a/ts/apps/bind/e2e/popup.ts b/ts/apps/bind/e2e/popup.ts index 860f0fd7..5d3987c0 100644 --- a/ts/apps/bind/e2e/popup.ts +++ b/ts/apps/bind/e2e/popup.ts @@ -1,5 +1,5 @@ import { setTimeout as sleep } from 'node:timers/promises' -import type { Page } from '@playwright/test' +import type { Page, Request } from '@playwright/test' import { BRIDGE } from '../local.ts' const BUDGET_MS = 180_000 @@ -37,7 +37,7 @@ export async function drivePopup(popup: Page, driver: Driver) { const deadline = Date.now() + BUDGET_MS // A request, not a committed page: the callback may redirect on at once. let returned = false - const onRequest = (request: { url(): string; isNavigationRequest(): boolean }) => { + const onRequest = (request: Request) => { if (request.isNavigationRequest() && isCallback(request.url())) returned = true } popup.on('request', onRequest) @@ -79,11 +79,17 @@ export async function drivePopup(popup: Page, driver: Driver) { } /** - * Run `mark`, a page script that tags the control to press with - * `data-libid-click` and returns whether it found one, then click that control - * for real. + * Evaluate `find`, a page expression for the control to press or nothing, + * then click that control for real. Returns whether there was one. */ -export async function clickMarked(popup: Page, mark: string): Promise { +export async function clickFound(popup: Page, find: string): Promise { + const mark = `(() => { + document.querySelectorAll('[data-libid-click]').forEach(e => e.removeAttribute('data-libid-click')); + const target = ${find}; + if (!target) return false; + target.setAttribute('data-libid-click', '1'); + return true; +})()` if (!(await popup.evaluate(mark).catch(() => false))) return false await popup .locator('[data-libid-click="1"]') diff --git a/ts/apps/bind/e2e/scan-secrets.ts b/ts/apps/bind/e2e/scan-secrets.ts index 26736a60..bbe8c495 100644 --- a/ts/apps/bind/e2e/scan-secrets.ts +++ b/ts/apps/bind/e2e/scan-secrets.ts @@ -3,12 +3,9 @@ // are the arguments; directories are searched. Nothing secret is printed. import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' -import { savedSession } from './session.ts' +import { googleSession, xSession } from './session.ts' -const sessions = [ - savedSession('X_TEST_ALICE', ['auth_token']), - savedSession('GOOGLE_TEST_ALICE', ['SID', '__Secure-1PSID', '__Secure-3PSID']), -] +const sessions = [xSession(), googleSession()] const secrets = [ process.env.GH_TEST_ALICE_PASSWORD, process.env.GH_TEST_ALICE_TOTP_SECRET, diff --git a/ts/apps/bind/e2e/session.ts b/ts/apps/bind/e2e/session.ts index 5bdfc9af..be5787c3 100644 --- a/ts/apps/bind/e2e/session.ts +++ b/ts/apps/bind/e2e/session.ts @@ -45,16 +45,30 @@ export function savedSession(prefix: string, required: string[]): Stored[] | und return cookies } -const sameSite = (value: string | null | undefined): 'Strict' | 'Lax' | 'None' | undefined => { - const lower = value?.toLowerCase() - return lower === 'strict' - ? 'Strict' - : lower === 'lax' - ? 'Lax' - : lower === 'none' || lower === 'no_restriction' - ? 'None' - : undefined -} +/** + * The saved X session, from X_TEST_ALICE_COOKIES or X_TEST_ALICE_COOKIES_FILE. + * Renew it with `cargo run --bin ceremony -- export x` in libid-server-rs' + * ceremony-tests, which lives on its feat/live-ceremony-tests branch + * (libid-server-rs PR #12). + */ +export const xSession = () => savedSession('X_TEST_ALICE', ['auth_token']) + +/** + * The saved Google session, from GOOGLE_TEST_ALICE_COOKIES or + * GOOGLE_TEST_ALICE_COOKIES_FILE. Renew it with `cargo run --bin ceremony -- + * export google` in libid-server-rs' ceremony-tests, which lives on its + * feat/live-ceremony-tests branch (libid-server-rs PR #12): Google refuses a + * sign-in in an automated browser, but honours a session made elsewhere. + */ +export const googleSession = () => + savedSession('GOOGLE_TEST_ALICE', ['SID', '__Secure-1PSID', '__Secure-3PSID']) + +const SAME_SITE = new Map([ + ['strict', 'Strict'], + ['lax', 'Lax'], + ['none', 'None'], + ['no_restriction', 'None'], +]) /** Put saved cookies in the browser context, on their own domain or each of `domainsOf`'s. */ export async function restoreSession( @@ -71,7 +85,7 @@ export async function restoreSession( path: cookie.path || '/', secure: cookie.secure ?? true, httpOnly: cookie.http_only ?? cookie.httpOnly ?? false, - sameSite: sameSite(cookie.same_site ?? cookie.sameSite), + sameSite: SAME_SITE.get((cookie.same_site ?? cookie.sameSite ?? '').toLowerCase()), expires: cookie.expires && cookie.expires > 0 ? cookie.expires : -1, })), ), diff --git a/ts/apps/bind/e2e/x.ts b/ts/apps/bind/e2e/x.ts index 10e20073..d37c6288 100644 --- a/ts/apps/bind/e2e/x.ts +++ b/ts/apps/bind/e2e/x.ts @@ -1,14 +1,6 @@ import type { BrowserContext, Page } from '@playwright/test' -import { clickMarked, drivePopup } from './popup.ts' -import { restoreSession, type Stored, savedSession } from './session.ts' - -/** - * The saved X session, from X_TEST_ALICE_COOKIES or X_TEST_ALICE_COOKIES_FILE. - * Renew it with `cargo run --bin ceremony -- export x` in libid-server-rs' - * ceremony-tests, which lives on its feat/live-ceremony-tests branch - * (libid-server-rs PR #12). - */ -export const xSession = () => savedSession('X_TEST_ALICE', ['auth_token']) +import { clickFound, drivePopup } from './popup.ts' +import { restoreSession, type Stored } from './session.ts' /** Put the saved session on both hosts X answers on, as the Rust suite does. */ export const restoreXSession = (context: BrowserContext, cookies: Stored[]) => @@ -29,15 +21,10 @@ const ACCEPT_COOKIES = `(() => { return false; })()` -/** The visible, enabled consent control marked for a real click; X serves more than one consent page. */ -const MARK_CONSENT = `(() => { - document.querySelectorAll('[data-libid-click]').forEach(e => e.removeAttribute('data-libid-click')); - const button = [...document.querySelectorAll("[data-testid='OAuth_Consent_Button'],button,[role=button],input[type=submit],a")].find(e => - (e.getAttribute('data-testid') === 'OAuth_Consent_Button' || /^(authorize app)$/i.test((e.innerText || e.value || '').trim())) && - !e.disabled && e.getAttribute('aria-disabled') !== 'true' && e.getClientRects().length > 0); - if (button) { button.setAttribute('data-libid-click', '1'); return true; } - return false; -})()` +/** The visible, enabled consent control; X serves more than one consent page. */ +const FIND_CONSENT = `[...document.querySelectorAll("[data-testid='OAuth_Consent_Button'],button,[role=button],input[type=submit],a")].find(e => + (e.getAttribute('data-testid') === 'OAuth_Consent_Button' || /^(authorize app)$/i.test((e.innerText || e.value || '').trim())) && + !e.disabled && e.getAttribute('aria-disabled') !== 'true' && e.getClientRects().length > 0)` const onX = (url: URL) => url.hostname === 'x.com' || url.hostname === 'twitter.com' || url.hostname.endsWith('.x.com') @@ -66,7 +53,7 @@ export async function authorizeOnX(popup: Page) { } else if (url.pathname.startsWith('/i/oauth2/authorize')) { challengedAt = undefined await popup.evaluate(ACCEPT_COOKIES).catch(() => {}) - if (Date.now() - consentedAt > 10_000 && (await clickMarked(popup, MARK_CONSENT))) + if (Date.now() - consentedAt > 10_000 && (await clickFound(popup, FIND_CONSENT))) consentedAt = Date.now() } }, diff --git a/ts/apps/bind/src/app.ts b/ts/apps/bind/src/app.ts index 05b8a341..6f02aad3 100644 --- a/ts/apps/bind/src/app.ts +++ b/ts/apps/bind/src/app.ts @@ -59,11 +59,19 @@ const show = (id: string, text: string) => { field(id).textContent = text } const status = (text: string) => show('status', text) +const messageOf = (error: unknown, fallback: string) => + error instanceof Error ? error.message : fallback +/** End the run with `message` as its outcome. */ +const fail = (message: string) => { + status(message) + show('outcome', `failed: ${message}`) +} const connectButton = document.querySelector('#connect')! const names: Record = { github: 'GitHub', x: 'X', google: 'Google' } +const platforms = Object.keys(names) as Platform[] const anchors = Object.fromEntries( - (Object.keys(names) as Platform[]).map((platform) => [ + platforms.map((platform) => [ platform, document.querySelector(`#bind-${platform}`)!, ]), @@ -186,9 +194,7 @@ function launch( } catch (error) { event.preventDefault() connection.close() - const message = error instanceof Error ? error.message : 'Unable to start the ceremony.' - status(message) - show('outcome', `failed: ${message}`) + fail(messageOf(error, 'Unable to start the ceremony.')) return } const off = ceremony.onStage((update) => { @@ -207,11 +213,7 @@ function launch( throw new Error(`${names[platform]} denied the authorization.`) await submit(platform, signer, result, transactionData, operationDomain) }) - .catch((error: unknown) => { - const message = error instanceof Error ? error.message : 'The binding failed.' - status(message) - show('outcome', `failed: ${message}`) - }) + .catch((error: unknown) => fail(messageOf(error, 'The binding failed.'))) .finally(() => { connection.close() off() @@ -220,9 +222,7 @@ function launch( async function initialize() { const client = await createCCDPClient({ oauthBridge: BRIDGE }) - const enabled = (Object.keys(names) as Platform[]).filter((platform) => - client.enabledPlatforms.includes(platform), - ) + const enabled = platforms.filter((platform) => client.enabledPlatforms.includes(platform)) const operationDomain = hexToBytes(await registry.read.OPERATION_DOMAIN()) connectButton.addEventListener('click', () => { connect().then( @@ -230,8 +230,7 @@ async function initialize() { for (const platform of enabled) anchors[platform].setAttribute('aria-disabled', 'false') status('Ready. Choose a platform to bind.') }, - (error: unknown) => - status(error instanceof Error ? error.message : 'Could not connect a wallet.'), + (error: unknown) => status(messageOf(error, 'Could not connect a wallet.')), ) }) for (const platform of enabled)