From fb3d0a7e1a559cfe23ad09f3dec42e65d2574c2e Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 1 Oct 2026 18:08:14 +0300 Subject: [PATCH 001/103] 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 002/103] 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 003/103] 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 004/103] 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 005/103] 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 006/103] 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 007/103] 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 008/103] 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 009/103] 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 010/103] 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 011/103] 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 012/103] 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 013/103] 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 014/103] 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 015/103] 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 016/103] 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 017/103] 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 018/103] 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 019/103] 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 020/103] 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 021/103] 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 022/103] 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 023/103] 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 024/103] 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) From be7190f30f360d42c75258879ba6df54a29f1f7f Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Tue, 6 Oct 2026 18:29:35 +0300 Subject: [PATCH 025/103] refactor(ceremony-live): rename the bind app to ceremony-live ts/apps/bind becomes ts/apps/ceremony-live and @libid/bind becomes @libid/ceremony-live, ahead of the app dropping the chain: what it runs daily is the CCDP ceremony, which the ceremony package does not yet hand to `bind`. The Compose project prefix, the workflow's paths and biome's ignores follow. No behaviour changes. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 12 ++++++------ ts/apps/{bind => ceremony-live}/.gitignore | 0 ts/apps/{bind => ceremony-live}/bridge-config.toml | 0 ts/apps/{bind => ceremony-live}/compose.yaml | 0 .../{bind => ceremony-live}/e2e/bind.live.spec.ts | 0 ts/apps/{bind => ceremony-live}/e2e/github.ts | 0 ts/apps/{bind => ceremony-live}/e2e/google.ts | 0 ts/apps/{bind => ceremony-live}/e2e/live.ts | 0 ts/apps/{bind => ceremony-live}/e2e/person.ts | 0 ts/apps/{bind => ceremony-live}/e2e/popup.ts | 0 ts/apps/{bind => ceremony-live}/e2e/scan-secrets.ts | 0 ts/apps/{bind => ceremony-live}/e2e/session.ts | 0 ts/apps/{bind => ceremony-live}/e2e/stealth.js | 0 ts/apps/{bind => ceremony-live}/e2e/wallet.ts | 0 ts/apps/{bind => ceremony-live}/e2e/x.ts | 0 ts/apps/{bind => ceremony-live}/keeper.toml | 0 ts/apps/{bind => ceremony-live}/local-dev.toml | 0 ts/apps/{bind => ceremony-live}/local.ts | 0 ts/apps/{bind => ceremony-live}/package.json | 4 ++-- .../playwright.live.config.ts | 0 ts/apps/{bind => ceremony-live}/src/app.ts | 0 ts/apps/{bind => ceremony-live}/src/evm.ts | 0 ts/apps/{bind => ceremony-live}/src/index.html | 0 ts/apps/{bind => ceremony-live}/stack.ts | 2 +- ts/apps/{bind => ceremony-live}/tsconfig.json | 0 ts/apps/{bind => ceremony-live}/vite.config.ts | 0 ts/biome.json | 4 ++-- ts/pnpm-lock.yaml | 2 +- 28 files changed, 12 insertions(+), 12 deletions(-) rename ts/apps/{bind => ceremony-live}/.gitignore (100%) rename ts/apps/{bind => ceremony-live}/bridge-config.toml (100%) rename ts/apps/{bind => ceremony-live}/compose.yaml (100%) rename ts/apps/{bind => ceremony-live}/e2e/bind.live.spec.ts (100%) rename ts/apps/{bind => ceremony-live}/e2e/github.ts (100%) rename ts/apps/{bind => ceremony-live}/e2e/google.ts (100%) rename ts/apps/{bind => ceremony-live}/e2e/live.ts (100%) rename ts/apps/{bind => ceremony-live}/e2e/person.ts (100%) rename ts/apps/{bind => ceremony-live}/e2e/popup.ts (100%) rename ts/apps/{bind => ceremony-live}/e2e/scan-secrets.ts (100%) rename ts/apps/{bind => ceremony-live}/e2e/session.ts (100%) rename ts/apps/{bind => ceremony-live}/e2e/stealth.js (100%) rename ts/apps/{bind => ceremony-live}/e2e/wallet.ts (100%) rename ts/apps/{bind => ceremony-live}/e2e/x.ts (100%) rename ts/apps/{bind => ceremony-live}/keeper.toml (100%) rename ts/apps/{bind => ceremony-live}/local-dev.toml (100%) rename ts/apps/{bind => ceremony-live}/local.ts (100%) rename ts/apps/{bind => ceremony-live}/package.json (88%) rename ts/apps/{bind => ceremony-live}/playwright.live.config.ts (100%) rename ts/apps/{bind => ceremony-live}/src/app.ts (100%) rename ts/apps/{bind => ceremony-live}/src/evm.ts (100%) rename ts/apps/{bind => ceremony-live}/src/index.html (100%) rename ts/apps/{bind => ceremony-live}/stack.ts (99%) rename ts/apps/{bind => ceremony-live}/tsconfig.json (100%) rename ts/apps/{bind => ceremony-live}/vite.config.ts (100%) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index bc5362f4..668326af 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -51,7 +51,7 @@ jobs: - uses: ./.github/actions/setup-ts - name: Install Chromium - run: pnpm -C ts --filter @libid/bind exec playwright install --with-deps chromium + run: pnpm -C ts --filter @libid/ceremony-live exec playwright install --with-deps chromium # Google is left out: it challenges a saved session arriving from a # runner's address ("Verify it's you"), which no automation passes. Its @@ -73,7 +73,7 @@ jobs: # 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 + run: pnpm -C ts/apps/ceremony-live test:live # 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' @@ -94,10 +94,10 @@ jobs: # 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 | + docker compose ls -q --all --filter name=libid-ceremony-live | 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" + node ts/apps/ceremony-live/e2e/scan-secrets.ts ts/apps/ceremony-live/test-results "$RUNNER_TEMP/stack-scanned.log" - name: Keep the screenshots and container logs if: ${{ !cancelled() && steps.scan.outcome == 'success' }} @@ -105,8 +105,8 @@ jobs: with: name: live-e2e path: | - ts/apps/bind/test-results/**/*.png - ts/apps/bind/test-results/**/*.md + ts/apps/ceremony-live/test-results/**/*.png + ts/apps/ceremony-live/test-results/**/*.md ${{ runner.temp }}/stack-scanned.log retention-days: 14 if-no-files-found: ignore diff --git a/ts/apps/bind/.gitignore b/ts/apps/ceremony-live/.gitignore similarity index 100% rename from ts/apps/bind/.gitignore rename to ts/apps/ceremony-live/.gitignore diff --git a/ts/apps/bind/bridge-config.toml b/ts/apps/ceremony-live/bridge-config.toml similarity index 100% rename from ts/apps/bind/bridge-config.toml rename to ts/apps/ceremony-live/bridge-config.toml diff --git a/ts/apps/bind/compose.yaml b/ts/apps/ceremony-live/compose.yaml similarity index 100% rename from ts/apps/bind/compose.yaml rename to ts/apps/ceremony-live/compose.yaml diff --git a/ts/apps/bind/e2e/bind.live.spec.ts b/ts/apps/ceremony-live/e2e/bind.live.spec.ts similarity index 100% rename from ts/apps/bind/e2e/bind.live.spec.ts rename to ts/apps/ceremony-live/e2e/bind.live.spec.ts diff --git a/ts/apps/bind/e2e/github.ts b/ts/apps/ceremony-live/e2e/github.ts similarity index 100% rename from ts/apps/bind/e2e/github.ts rename to ts/apps/ceremony-live/e2e/github.ts diff --git a/ts/apps/bind/e2e/google.ts b/ts/apps/ceremony-live/e2e/google.ts similarity index 100% rename from ts/apps/bind/e2e/google.ts rename to ts/apps/ceremony-live/e2e/google.ts diff --git a/ts/apps/bind/e2e/live.ts b/ts/apps/ceremony-live/e2e/live.ts similarity index 100% rename from ts/apps/bind/e2e/live.ts rename to ts/apps/ceremony-live/e2e/live.ts diff --git a/ts/apps/bind/e2e/person.ts b/ts/apps/ceremony-live/e2e/person.ts similarity index 100% rename from ts/apps/bind/e2e/person.ts rename to ts/apps/ceremony-live/e2e/person.ts diff --git a/ts/apps/bind/e2e/popup.ts b/ts/apps/ceremony-live/e2e/popup.ts similarity index 100% rename from ts/apps/bind/e2e/popup.ts rename to ts/apps/ceremony-live/e2e/popup.ts diff --git a/ts/apps/bind/e2e/scan-secrets.ts b/ts/apps/ceremony-live/e2e/scan-secrets.ts similarity index 100% rename from ts/apps/bind/e2e/scan-secrets.ts rename to ts/apps/ceremony-live/e2e/scan-secrets.ts diff --git a/ts/apps/bind/e2e/session.ts b/ts/apps/ceremony-live/e2e/session.ts similarity index 100% rename from ts/apps/bind/e2e/session.ts rename to ts/apps/ceremony-live/e2e/session.ts diff --git a/ts/apps/bind/e2e/stealth.js b/ts/apps/ceremony-live/e2e/stealth.js similarity index 100% rename from ts/apps/bind/e2e/stealth.js rename to ts/apps/ceremony-live/e2e/stealth.js diff --git a/ts/apps/bind/e2e/wallet.ts b/ts/apps/ceremony-live/e2e/wallet.ts similarity index 100% rename from ts/apps/bind/e2e/wallet.ts rename to ts/apps/ceremony-live/e2e/wallet.ts diff --git a/ts/apps/bind/e2e/x.ts b/ts/apps/ceremony-live/e2e/x.ts similarity index 100% rename from ts/apps/bind/e2e/x.ts rename to ts/apps/ceremony-live/e2e/x.ts diff --git a/ts/apps/bind/keeper.toml b/ts/apps/ceremony-live/keeper.toml similarity index 100% rename from ts/apps/bind/keeper.toml rename to ts/apps/ceremony-live/keeper.toml diff --git a/ts/apps/bind/local-dev.toml b/ts/apps/ceremony-live/local-dev.toml similarity index 100% rename from ts/apps/bind/local-dev.toml rename to ts/apps/ceremony-live/local-dev.toml diff --git a/ts/apps/bind/local.ts b/ts/apps/ceremony-live/local.ts similarity index 100% rename from ts/apps/bind/local.ts rename to ts/apps/ceremony-live/local.ts diff --git a/ts/apps/bind/package.json b/ts/apps/ceremony-live/package.json similarity index 88% rename from ts/apps/bind/package.json rename to ts/apps/ceremony-live/package.json index 4bb47287..5c45ae87 100644 --- a/ts/apps/bind/package.json +++ b/ts/apps/ceremony-live/package.json @@ -1,5 +1,5 @@ { - "name": "@libid/bind", + "name": "@libid/ceremony-live", "version": "0.0.0", "private": true, "type": "module", @@ -8,7 +8,7 @@ "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:deps": "pnpm --filter '@libid/ceremony-live^...' build", "build": "vite build", "typecheck": "tsc --noEmit", "test:live": "playwright test -c playwright.live.config.ts" diff --git a/ts/apps/bind/playwright.live.config.ts b/ts/apps/ceremony-live/playwright.live.config.ts similarity index 100% rename from ts/apps/bind/playwright.live.config.ts rename to ts/apps/ceremony-live/playwright.live.config.ts diff --git a/ts/apps/bind/src/app.ts b/ts/apps/ceremony-live/src/app.ts similarity index 100% rename from ts/apps/bind/src/app.ts rename to ts/apps/ceremony-live/src/app.ts diff --git a/ts/apps/bind/src/evm.ts b/ts/apps/ceremony-live/src/evm.ts similarity index 100% rename from ts/apps/bind/src/evm.ts rename to ts/apps/ceremony-live/src/evm.ts diff --git a/ts/apps/bind/src/index.html b/ts/apps/ceremony-live/src/index.html similarity index 100% rename from ts/apps/bind/src/index.html rename to ts/apps/ceremony-live/src/index.html diff --git a/ts/apps/bind/stack.ts b/ts/apps/ceremony-live/stack.ts similarity index 99% rename from ts/apps/bind/stack.ts rename to ts/apps/ceremony-live/stack.ts index db0a5ff3..135bf396 100644 --- a/ts/apps/bind/stack.ts +++ b/ts/apps/ceremony-live/stack.ts @@ -34,7 +34,7 @@ import { 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 project = `libid-ceremony-live-${createHash('sha256').update(root).digest('hex').slice(0, 12)}` const composeArgs = ['compose', '-p', project, '-f', join(root, 'compose.yaml')] // Docker publishes on 127.0.0.1; the browser reaches Bridge as localhost. diff --git a/ts/apps/bind/tsconfig.json b/ts/apps/ceremony-live/tsconfig.json similarity index 100% rename from ts/apps/bind/tsconfig.json rename to ts/apps/ceremony-live/tsconfig.json diff --git a/ts/apps/bind/vite.config.ts b/ts/apps/ceremony-live/vite.config.ts similarity index 100% rename from ts/apps/bind/vite.config.ts rename to ts/apps/ceremony-live/vite.config.ts diff --git a/ts/biome.json b/ts/biome.json index 47a6466b..11f27ae4 100644 --- a/ts/biome.json +++ b/ts/biome.json @@ -12,8 +12,8 @@ "!**/playwright-report", "!**/coverage", "!apps/dev/.cache", - "!apps/bind/.cache", - "!apps/bind/e2e/stealth.js", + "!apps/ceremony-live/.cache", + "!apps/ceremony-live/e2e/stealth.js", "!.cache" ] }, diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index d7287f27..4c881aec 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -36,7 +36,7 @@ importers: specifier: ^1.86.0 version: 1.86.0 - apps/bind: + apps/ceremony-live: dependencies: '@libid/ceremony': specifier: workspace:^ From cf159a782388ec264f683d886f4918c4ebe84d97 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Tue, 6 Oct 2026 18:33:21 +0300 Subject: [PATCH 026/103] refactor(ceremony-live): run the CCDP ceremony live, without a chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ceremony package does not hand its result to `bind` yet, so the live test covers the ceremony alone. - Stack: the notary, Bridge and the CCDP static server. anvil, the libid-deploy download and its pins, the keeper and keeper.toml, postgres, the indexer and its API, local-dev.toml and the address-drift check go. The checkout lock, the port check (notary, Bridge, CCDP), the LIBID_STACK_LOGS routing, the fresh start and the stop handling stay. The notary no longer opens the keeper's MPC-TLS port. - App: no wallet and no chain reads. A link per platform starts a ceremony with the same CCDP client; the page shows the proved account (name and id), the platform, the ceremony version, the notary attestations, the ZK proof's length and expiresAt, and one outcome: `proved`, or `failed: …` for a denial, an error or a ceremony that cannot start. The ceremony's ledger (chain 31337), operation domain (keccak256("libid.claim-identity")) and transaction data (abi.encode(a fixed holder, 0, address 0)) are fixed test values in src/operation.ts; nothing verifies the proof on a chain. The bind payload encoders and the injected wallet go. - Test: per platform, sign in with the same drivers, wait for the outcome and check `proved`, the account, the id's shape (GitHub and X numeric, Google 0x and 64 hex digits), the ceremony version, a non-empty proof and an expiresAt in the future. - Dependencies: viem and @libid/contracts go; @noble/hashes hashes the test values. Vite loses the indexer proxy; local.ts the chain constants. The stack's start budget is 10 minutes. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/ceremony-live/compose.yaml | 86 +------ ts/apps/ceremony-live/e2e/bind.live.spec.ts | 92 ------- .../ceremony-live/e2e/ceremony.live.spec.ts | 87 +++++++ ts/apps/ceremony-live/e2e/live.ts | 2 +- ts/apps/ceremony-live/e2e/wallet.ts | 29 --- ts/apps/ceremony-live/keeper.toml | 10 - ts/apps/ceremony-live/local-dev.toml | 92 ------- ts/apps/ceremony-live/local.ts | 29 +-- ts/apps/ceremony-live/package.json | 5 +- .../ceremony-live/playwright.live.config.ts | 4 +- ts/apps/ceremony-live/src/app.ts | 209 +++------------- ts/apps/ceremony-live/src/evm.ts | 124 ---------- ts/apps/ceremony-live/src/index.html | 28 +-- ts/apps/ceremony-live/src/operation.ts | 26 ++ ts/apps/ceremony-live/stack.ts | 229 +++--------------- ts/apps/ceremony-live/vite.config.ts | 11 +- ts/pnpm-lock.yaml | 34 ++- 17 files changed, 227 insertions(+), 870 deletions(-) delete mode 100644 ts/apps/ceremony-live/e2e/bind.live.spec.ts create mode 100644 ts/apps/ceremony-live/e2e/ceremony.live.spec.ts delete mode 100644 ts/apps/ceremony-live/e2e/wallet.ts delete mode 100644 ts/apps/ceremony-live/keeper.toml delete mode 100644 ts/apps/ceremony-live/local-dev.toml delete mode 100644 ts/apps/ceremony-live/src/evm.ts create mode 100644 ts/apps/ceremony-live/src/operation.ts diff --git a/ts/apps/ceremony-live/compose.yaml b/ts/apps/ceremony-live/compose.yaml index 81a2b9e1..72509e87 100644 --- a/ts/apps/ceremony-live/compose.yaml +++ b/ts/apps/ceremony-live/compose.yaml @@ -1,20 +1,6 @@ -# 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. +# Started by stack.ts after building CCDP: the notary, Bridge and the CCDP +# static server, everything a ceremony needs. No chain. services: - anvil: - # 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", "--block-time", "1", "--silent"] - 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 @@ -25,10 +11,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. + # A public test key, never a production notary identity. Nothing here + # verifies the attestations it signs on a chain. SIGNING_KEY: "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" ports: - "127.0.0.1:4687:7048" @@ -66,68 +50,6 @@ 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 - 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" - # A local chain does not reorganize: 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 - keeper: - file: ./keeper.toml diff --git a/ts/apps/ceremony-live/e2e/bind.live.spec.ts b/ts/apps/ceremony-live/e2e/bind.live.spec.ts deleted file mode 100644 index 13e4ea2c..00000000 --- a/ts/apps/ceremony-live/e2e/bind.live.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { type BrowserContext, expect, type Page, test } from '@playwright/test' -import { RPC_URL } from '../local.ts' -import { authorizeOnGitHub, githubAccount } from './github.ts' -import { authorizeOnGoogle } from './google.ts' -import { isLive, liveSecret } from './live.ts' -import { presentAsPerson } from './person.ts' -import { googleSession, restoreSession, xSession } from './session.ts' -import { injectWallet } from './wallet.ts' -import { authorizeOnX, restoreXSession } from './x.ts' - -/** anvil account #2: unlocked by anvil, funded, and not one the deploy uses. */ -const HOLDER = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' -/** 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 - * `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' | 'Google', - 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 ${platform}` }).click(), - ]) - await authorize(popup) - - // 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')) -} - -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) => - authorizeOnGitHub(popup, account!), - ) -}) - -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 = liveSecret('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)) -}) - -test('a Google identity is proved in the browser, bound on chain, and shown', async ({ - 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 = liveSecret('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/ceremony-live/e2e/ceremony.live.spec.ts b/ts/apps/ceremony-live/e2e/ceremony.live.spec.ts new file mode 100644 index 00000000..168ec24b --- /dev/null +++ b/ts/apps/ceremony-live/e2e/ceremony.live.spec.ts @@ -0,0 +1,87 @@ +import { type BrowserContext, expect, type Page, test } from '@playwright/test' +import { authorizeOnGitHub, githubAccount } from './github.ts' +import { authorizeOnGoogle } from './google.ts' +import { isLive, liveSecret } from './live.ts' +import { presentAsPerson } from './person.ts' +import { googleSession, restoreSession, xSession } from './session.ts' +import { authorizeOnX, restoreXSession } from './x.ts' + +/** Proving in the browser, after the platform returns the popup. */ +const OUTCOME_TIMEOUT = 8 * 60_000 + +type Name = 'GitHub' | 'X' | 'Google' + +const expected: Record = { + GitHub: { platform: 'github', userId: /^\d+$/ }, + X: { platform: 'x', userId: /^\d+$/ }, + // The token's `sub`, hashed: no Google account id reaches the page. + Google: { platform: 'google', userId: /^0x[0-9a-f]{64}$/ }, +} + +/** + * Start the platform's ceremony from its link, let `authorize` drive the popup + * through the platform, then check what the page reports: the proved account, + * its id's shape, the ceremony and a non-empty, unexpired proof. + */ +async function proveThroughTheUi( + page: Page, + context: BrowserContext, + name: Name, + expectedUser: string, + authorize: (popup: Page) => Promise, +) { + await page.goto('/') + await expect(page.getByTestId('status')).toHaveText('Choose a platform to prove.') + const [popup] = await Promise.all([ + context.waitForEvent('page'), + page.getByRole('link', { name: `Prove ${name}` }).click(), + ]) + await authorize(popup) + + // 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('proved') + await expect(page.getByTestId('user-name')).toHaveText(expectedUser, { ignoreCase: true }) + await expect(page.getByTestId('user-id')).toHaveText(expected[name].userId) + await expect(page.getByTestId('platform')).toHaveText(expected[name].platform) + await expect(page.getByTestId('ceremony-version')).toHaveText(/^[1-9]\d*$/) + await expect(page.getByTestId('attestations')).not.toHaveText('—') + expect(Number(await page.getByTestId('proof-bytes').textContent())).toBeGreaterThan(0) + expect(Number(await page.getByTestId('expires-at').textContent())).toBeGreaterThan( + Date.now() / 1000, + ) +} + +test('a GitHub identity is proved in the browser', 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 proveThroughTheUi(page, context, 'GitHub', account!.username, (popup) => + authorizeOnGitHub(popup, account!), + ) +}) + +test('an X identity is proved in the browser', 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 = liveSecret('X_TEST_ALICE_USERNAME') + test.skip(!username, 'X_TEST_ALICE_USERNAME is not set') + await presentAsPerson(context) + await restoreXSession(context, session!) + await proveThroughTheUi(page, context, 'X', username!, (popup) => authorizeOnX(popup)) +}) + +test('a Google identity is proved in the browser', async ({ 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 = liveSecret('GOOGLE_TEST_ALICE_EMAIL') + test.skip(!email, 'GOOGLE_TEST_ALICE_EMAIL is not set') + await presentAsPerson(context) + await restoreSession(context, session!) + await proveThroughTheUi(page, context, 'Google', email!, (popup) => + authorizeOnGoogle(popup, email!), + ) +}) diff --git a/ts/apps/ceremony-live/e2e/live.ts b/ts/apps/ceremony-live/e2e/live.ts index 0fbb6e44..b313b3cf 100644 --- a/ts/apps/ceremony-live/e2e/live.ts +++ b/ts/apps/ceremony-live/e2e/live.ts @@ -1,7 +1,7 @@ import { livePlatforms, type Platform } from '../local.ts' /** - * Whether this run binds `platform`. A platform left out of + * Whether this run proves `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) diff --git a/ts/apps/ceremony-live/e2e/wallet.ts b/ts/apps/ceremony-live/e2e/wallet.ts deleted file mode 100644 index 9762f8df..00000000 --- a/ts/apps/ceremony-live/e2e/wallet.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/ceremony-live/keeper.toml b/ts/apps/ceremony-live/keeper.toml deleted file mode 100644 index 93427dfa..00000000 --- a/ts/apps/ceremony-live/keeper.toml +++ /dev/null @@ -1,10 +0,0 @@ -# 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/ceremony-live/local-dev.toml b/ts/apps/ceremony-live/local-dev.toml deleted file mode 100644 index 84e9fda2..00000000 --- a/ts/apps/ceremony-live/local-dev.toml +++ /dev/null @@ -1,92 +0,0 @@ -# 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/ceremony-live/local.ts b/ts/apps/ceremony-live/local.ts index 3f1ab37a..b20c8a25 100644 --- a/ts/apps/ceremony-live/local.ts +++ b/ts/apps/ceremony-live/local.ts @@ -1,36 +1,15 @@ -// 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, and -// stack.ts refuses to start when a copy differs. +// The local stack's ports, for the stack, the app and the tests. +// compose.yaml 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. */ +/** The 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' -/** - * 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' -/** - * 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. - */ +/** The platforms a live run proves: LIBID_LIVE_PLATFORMS, comma-separated, or all three. */ 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/ceremony-live/package.json b/ts/apps/ceremony-live/package.json index 5c45ae87..d2173c47 100644 --- a/ts/apps/ceremony-live/package.json +++ b/ts/apps/ceremony-live/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "An application that binds a platform identity on a local chain, end to end.", + "description": "An application that runs the CCDP ceremony for a platform identity in the browser, live.", "license": "(MIT OR Apache-2.0)", "scripts": { "dev": "pnpm build:deps && node stack.ts --app", @@ -15,10 +15,9 @@ }, "dependencies": { "@libid/ceremony": "workspace:^", - "@libid/contracts": "0.15.1", "@libid/ledger": "workspace:^", "@libid/popup": "workspace:^", - "viem": "^2.48.11" + "@noble/hashes": "^2.4.0" }, "devDependencies": { "@playwright/test": "catalog:", diff --git a/ts/apps/ceremony-live/playwright.live.config.ts b/ts/apps/ceremony-live/playwright.live.config.ts index e46afbc9..cd0b76a4 100644 --- a/ts/apps/ceremony-live/playwright.live.config.ts +++ b/ts/apps/ceremony-live/playwright.live.config.ts @@ -13,7 +13,7 @@ if (envFile) { process.env[name] ??= value } -/** Live: real platforms, real notary, real chain. */ +/** Live: real platforms, real notary, real Bridge; no chain. */ export default defineConfig({ testDir: 'e2e', testMatch: '*.live.spec.ts', @@ -27,7 +27,7 @@ export default defineConfig({ command: 'pnpm dev', url: APP_ORIGIN, reuseExistingServer: !process.env.CI, - timeout: 15 * 60_000, + timeout: 10 * 60_000, gracefulShutdown: { signal: 'SIGTERM', timeout: 120_000 }, // CI keeps the stack's output off the public job log: the stack writes // it to LIBID_STACK_LOGS, which is scanned before upload. diff --git a/ts/apps/ceremony-live/src/app.ts b/ts/apps/ceremony-live/src/app.ts index 6f02aad3..f41794be 100644 --- a/ts/apps/ceremony-live/src/app.ts +++ b/ts/apps/ceremony-live/src/app.ts @@ -4,55 +4,12 @@ import { CeremonyStage, createCCDPClient, type IdentityResult, - type OAuthProof, } from '@libid/ceremony' -import { identityRegistryAbi, platformId, resolveId } from '@libid/contracts' import { PopupWindow } from '@libid/popup' -import { - type Address, - createPublicClient, - createWalletClient, - custom, - defineChain, - type EIP1193Provider, - getContract, - hexToBytes, - http, - isAddressEqual, - type WalletClient, -} from 'viem' -import { privateKeyToAccount } from 'viem/accounts' -import { - BRIDGE, - CHAIN_ID, - NOTARY, - type Platform, - REGISTRY, - RPC_URL, - VERIFIER_VERSION, -} from '../local.ts' -import { - authorizedTransactionData, - encodeGooglePayload, - encodeTlsNotaryPayload, - evmLedger, -} from './evm.ts' +import { BRIDGE, NOTARY, type Platform } from '../local.ts' +import { evmLedger, operationDomain, transactionData } from './operation.ts' -declare global { - interface Window { - ethereum?: EIP1193Provider - } -} - -const chain = defineChain({ - 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 = getContract({ address: REGISTRY, abi: identityRegistryAbi, client: publicClient }) -const ledger = evmLedger(chain.id, NOTARY) +const ledger = evmLedger(NOTARY) const field = (id: string) => document.querySelector(`[data-testid="${id}"]`)! const show = (id: string, text: string) => { @@ -66,128 +23,46 @@ 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( platforms.map((platform) => [ platform, - document.querySelector(`#bind-${platform}`)!, + document.querySelector(`#prove-${platform}`)!, ]), ) as Record -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) -} - 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, - ) +/** + * The proof's notary attestations and the length of its ZK proof: GitHub and X + * carry a token and an identity attestation beside the bearer-link proof; + * Google's ID-token proof needs none. + */ +function summary({ oauthProof: { proof } }: Accepted): { attestations: string; bytes: number } { + if ('identityProof' in proof) return { attestations: 'none', bytes: proof.identityProof.length } + return { attestations: 'token, identity', bytes: proof.bearerLinkProof.length } } -/** The wallet and holder a ceremony started with; its transaction data names this holder. */ -interface Signer { - wallet: WalletClient - holder: Address +function showResult(result: Accepted) { + const { identity, oauthProof } = result + show('user-name', identity.userName) + show('user-id', identity.userId) + show('platform', identity.platformId) + show('ceremony-version', String(oauthProof.platformCeremonyVersion)) + const { attestations, bytes } = summary(result) + show('attestations', attestations) + show('proof-bytes', String(bytes)) + show('expires-at', String(oauthProof.expiresAt)) + status('Proved.') + show('outcome', 'proved') } -async function submit( - platform: Platform, - { wallet, holder }: Signer, - result: Accepted, - transactionData: Uint8Array, - operationDomain: Uint8Array, -) { - show('proof-received', `${result.identity.userName} (${result.identity.userId})`) - const id = platformId(platform) - 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, VERIFIER_VERSION, payload, true], { - account: holder, - 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( - { client: publicClient, address: REGISTRY }, - id, - 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…') - 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; 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) { - 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 undefined -} - -/** 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, -) { - if (!wallet || !holder) { - event.preventDefault() - return - } - const signer: Signer = { wallet, holder } +/** Start a ceremony for `platform` from its anchor's click, then show what it proves. */ +function launch(client: CCDPClient, platform: Platform, event: MouseEvent) { // Window creation and the run's start stay synchronous with the click. const connection = client.connect(PopupWindow.fromAnchor(event)) - const transactionData = authorizedTransactionData(signer.holder) let ceremony: Ceremony try { ceremony = client.new(connection, platform, ledger, operationDomain, transactionData) @@ -207,13 +82,12 @@ function launch( }) void ceremony .proveUserIdentity() - .then(async (result) => { - connection.close() + .then((result) => { if (result.status !== 'accepted') throw new Error(`${names[platform]} denied the authorization.`) - await submit(platform, signer, result, transactionData, operationDomain) + showResult(result) }) - .catch((error: unknown) => fail(messageOf(error, 'The binding failed.'))) + .catch((error: unknown) => fail(messageOf(error, 'The ceremony failed.'))) .finally(() => { connection.close() off() @@ -222,26 +96,13 @@ function launch( async function initialize() { const client = await createCCDPClient({ oauthBridge: BRIDGE }) - const enabled = platforms.filter((platform) => client.enabledPlatforms.includes(platform)) - const operationDomain = hexToBytes(await registry.read.OPERATION_DOMAIN()) - connectButton.addEventListener('click', () => { - connect().then( - () => { - for (const platform of enabled) anchors[platform].setAttribute('aria-disabled', 'false') - status('Ready. Choose a platform to bind.') - }, - (error: unknown) => status(messageOf(error, 'Could not connect a wallet.')), - ) - }) - for (const platform of enabled) - anchors[platform].addEventListener('click', (event) => - launch(client, platform, event, operationDomain), - ) - status('Connect a wallet to start.') + for (const platform of platforms.filter((p) => client.enabledPlatforms.includes(p))) { + anchors[platform].setAttribute('aria-disabled', 'false') + anchors[platform].addEventListener('click', (event) => launch(client, platform, event)) + } + status('Choose a platform to prove.') } void initialize().catch((error: unknown) => - status( - `Could not start: ${error instanceof Error ? error.message : String(error)}. Is the stack running?`, - ), + status(`Could not start: ${messageOf(error, String(error))}. Is the stack running?`), ) diff --git a/ts/apps/ceremony-live/src/evm.ts b/ts/apps/ceremony-live/src/evm.ts deleted file mode 100644 index 3e50b937..00000000 --- a/ts/apps/ceremony-live/src/evm.ts +++ /dev/null @@ -1,124 +0,0 @@ -// 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, - stringToHex, - 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), - }, - ]) -} - -/** `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/ceremony-live/src/index.html b/ts/apps/ceremony-live/src/index.html index 482b6c89..10ca9a8e 100644 --- a/ts/apps/ceremony-live/src/index.html +++ b/ts/apps/ceremony-live/src/index.html @@ -3,7 +3,7 @@ - Bind an identity + Prove an identity -

Bind an identity

-

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

+

Prove an identity

+

Prove a GitHub, X or Google account in the browser through the CCDP ceremony. Nothing is sent to a chain.

-
Wallet
—
Stage
—
-
Proved account
—
-
Transaction
—
-
Receipt
—
-
Registry says
—
-
Indexer says
—
+
Account
—
+
User ID
—
+
Platform
—
+
Ceremony version
—
+
Attestations
—
+
Proof bytes
—
+
Expires at
—
Outcome
—
diff --git a/ts/apps/ceremony-live/src/operation.ts b/ts/apps/ceremony-live/src/operation.ts new file mode 100644 index 00000000..06cd75e7 --- /dev/null +++ b/ts/apps/ceremony-live/src/operation.ts @@ -0,0 +1,26 @@ +// The operation a ceremony is started for. A ceremony binds its proof to a +// ledger, an operation domain and transaction data; these are fixed test +// values shaped as an EVM `bind` would supply them, so the proof is +// well-formed. Nothing verifies it on a chain. +import type { LedgerId } from '@libid/ledger' +import { keccak_256 } from '@noble/hashes/sha3.js' +import { hexToBytes } from '@noble/hashes/utils.js' + +/** The EVM ledger of chain 31337, anvil's default id. */ +const CHAIN_ID = 31337n + +/** A fixed holder for the transaction data; no account behind it is used. */ +const HOLDER = '1111111111111111111111111111111111111111' + +/** An EVM ledger routed through `notaryUrl`. */ +export function evmLedger(notaryUrl: string): LedgerId { + // specs/chain-profiles.md §3.1: keccak256 of the 32-byte big-endian EIP-155 id. + const hash = keccak_256(hexToBytes(CHAIN_ID.toString(16).padStart(64, '0'))) + return { hash: () => hash.slice(), notaryAddress: () => notaryUrl } +} + +/** IdentityRegistry's OPERATION_DOMAIN: keccak256("libid.claim-identity"). */ +export const operationDomain = keccak_256(new TextEncoder().encode('libid.claim-identity')) + +/** `bind`'s Authorized Transaction Data: abi.encode(HOLDER, uint256 0, address 0). */ +export const transactionData = hexToBytes(`${'0'.repeat(24)}${HOLDER}${'0'.repeat(128)}`) diff --git a/ts/apps/ceremony-live/stack.ts b/ts/apps/ceremony-live/stack.ts index 135bf396..7be430d4 100644 --- a/ts/apps/ceremony-live/stack.ts +++ b/ts/apps/ceremony-live/stack.ts @@ -1,36 +1,17 @@ -// 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. +// Start the notary, Bridge and CCDP; with --app, also the ceremony app. No +// OAuth mocks and no chain. // // 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 { spawn } from 'node:child_process' import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { 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' import { fileURLToPath } from 'node:url' -import { - ceremonyProofVerifierAbi, - googleJwtRootsAbi, - identityRegistryAbi, - platformId, -} from '@libid/contracts' -import { createPublicClient, http } from 'viem' import { createServer as createViteServer, type ViteDevServer } from 'vite' -import { - API, - APP_ORIGIN, - BRIDGE, - CHAIN_ID, - GOOGLE_JWT_ROOTS, - livePlatforms, - NOTARY, - REGISTRY, - RPC_URL, - VERIFIER_VERSION, -} from './local.ts' +import { APP_ORIGIN, BRIDGE, NOTARY } from './local.ts' const root = fileURLToPath(new URL('.', import.meta.url)) const cache = join(root, '.cache') @@ -39,8 +20,8 @@ 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_ON_HOST = BRIDGE.replace('localhost', '127.0.0.1') -const chain = createPublicClient({ transport: http(RPC_URL) }) -const platforms = livePlatforms(process.env) +/** CCDP's static server, published beside Bridge (compose.yaml). */ +const CCDP = 'http://127.0.0.1:4683' const logs = process.env.LIBID_STACK_LOGS /** Where children write: the LIBID_STACK_LOGS file, or our own output. */ @@ -51,16 +32,6 @@ const childOutput = logs })() : 'inherit' -/** chain-configurations' deploy tool; it deploys the real verifiers, not stubs. */ -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', -} - /** Aborted once the stack stops: waits end and running commands are terminated. */ const stopping = new AbortController() const { signal } = stopping @@ -98,29 +69,6 @@ async function answers(url: string, init: RequestInit = {}): Promise { 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] - 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}/${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 !== sha256) - throw new Error(`libid-deploy archive digest ${digest} is not the pinned one`) - execFileSync('tar', ['xz', '-C', cache], { input: archive }) - return binary -} - /** Whether something on this host accepts connections on `port`. */ function listening(port: number): Promise { return new Promise((resolve) => { @@ -135,11 +83,11 @@ function listening(port: number): Promise { /** * 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. + * already taken, the readiness check could pass against that stack and the + * tests would run against it. */ async function portsFree() { - const ports = [RPC_URL, NOTARY, BRIDGE, API].map((url) => Number(new URL(url).port)) + const ports = [NOTARY, BRIDGE, CCDP].map((url) => Number(new URL(url).port)) const open = await Promise.all(ports.map(listening)) const taken = ports.filter((_, index) => open[index]) if (taken.length) @@ -148,48 +96,6 @@ 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') @@ -232,117 +138,34 @@ function releaseLock() { } } -/** 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'), - '--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, - abi: identityRegistryAbi, - functionName: 'quoteBind', - args: [platformId('github'), VERIFIER_VERSION], - }) - if (quote !== 2_000_000_000_000_000n) - 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 platforms) { - 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}`) - } -} - -/** - * 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() { - await run('docker', [...composeArgs, '--profile', 'keeper', 'run', '--rm', 'keeper']) - const stale = await chain.readContract({ - address: GOOGLE_JWT_ROOTS, - abi: googleJwtRootsAbi, - functionName: 'needsRotation', - }) - 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() { - 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(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 ─────────────────────────────────────────────────────── try { - configAgrees() takeLock() } catch (error) { fail(error) } -// 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 +// CCDP builds while Docker clears a stack this checkout left behind, so every +// run starts fresh; 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'), - ]), -) +const ccdp = run('pnpm', [ + '--filter', + '@libid/ceremony', + 'build:ccdp-artifacts', + '--out-dir', + join(cache, 'ccdp'), +]) +ccdp.catch(() => {}) try { await Promise.all([ run('docker', [...composeArgs, 'down', '--volumes', '--remove-orphans']), - run('docker', [...composeArgs, '--profile', 'keeper', 'pull', '--quiet']), + run('docker', [...composeArgs, 'pull', '--quiet']), ]) } catch { fail( @@ -389,10 +212,14 @@ compose.on('error', () => { compose.on('exit', (code) => stop(code || 1)) try { - await until('anvil', async () => (await chain.getChainId()) === CHAIN_ID) - await Promise.all([chainReady(tool), ready()]) + await until('Bridge', () => + answers(`${BRIDGE_ON_HOST}/api/v1/ceremony/config`, { + headers: { Origin: APP_ORIGIN }, + redirect: 'error', + }), + ) signal.throwIfAborted() - console.info(`Stack ready: chain ${RPC_URL}, registry ${REGISTRY}, indexer ${API}`) + console.info(`Stack ready: Bridge ${BRIDGE}, notary ${NOTARY}`) if (process.argv.includes('--app')) { frontend = await createViteServer({ configFile: join(root, 'vite.config.ts') }) if (signal.aborted) await frontend.close() diff --git a/ts/apps/ceremony-live/vite.config.ts b/ts/apps/ceremony-live/vite.config.ts index 0d1653d6..90add309 100644 --- a/ts/apps/ceremony-live/vite.config.ts +++ b/ts/apps/ceremony-live/vite.config.ts @@ -1,10 +1,10 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' -import { API, APP_PORT } from './local.ts' +import { APP_PORT } from './local.ts' const root = fileURLToPath(new URL('.', import.meta.url)) -const directory = join(root, '.cache/bind') +const directory = join(root, '.cache/app') export default defineConfig({ root: join(root, 'src'), cacheDir: join(directory, 'vite'), @@ -13,13 +13,6 @@ export default defineConfig({ host: 'localhost', port: APP_PORT, strictPort: true, - // The indexer's read API, same-origin so the page needs no CORS from it. - proxy: { - '/indexer': { - target: API, - 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 4c881aec..d40dc546 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -41,18 +41,15 @@ importers: '@libid/ceremony': specifier: workspace:^ version: link:../../packages/ceremony - '@libid/contracts': - specifier: 0.15.1 - version: 0.15.1(viem@2.57.3(typescript@7.0.2)) '@libid/ledger': specifier: workspace:^ version: link:../../packages/ledger '@libid/popup': specifier: workspace:^ version: link:../../packages/popup - viem: - specifier: ^2.48.11 - version: 2.57.3(typescript@7.0.2) + '@noble/hashes': + specifier: ^2.4.0 + version: 2.4.0 devDependencies: '@playwright/test': specifier: 'catalog:' @@ -1318,7 +1315,8 @@ packages: snapshots: - '@adraffy/ens-normalize@1.11.1': {} + '@adraffy/ens-normalize@1.11.1': + optional: true '@aztec-foundation/bb.js@6.0.0-rc.2': dependencies: @@ -1493,13 +1491,16 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': optional: true - '@noble/ciphers@1.3.0': {} + '@noble/ciphers@1.3.0': + optional: true '@noble/curves@1.9.1': dependencies: '@noble/hashes': 1.8.0 + optional: true - '@noble/hashes@1.8.0': {} + '@noble/hashes@1.8.0': + optional: true '@noble/hashes@2.4.0': {} @@ -1628,18 +1629,21 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@scure/base@1.2.6': {} + '@scure/base@1.2.6': + optional: true '@scure/bip32@1.7.0': dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 + optional: true '@scure/bip39@1.6.0': dependencies: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 + optional: true '@types/chai@5.2.3': dependencies: @@ -1746,6 +1750,7 @@ snapshots: abitype@1.2.3(typescript@7.0.2): optionalDependencies: typescript: 7.0.2 + optional: true assertion-error@2.0.1: {} @@ -1801,7 +1806,8 @@ snapshots: dependencies: '@types/estree': 1.0.9 - eventemitter3@5.0.1: {} + eventemitter3@5.0.1: + optional: true expect-type@1.4.0: {} @@ -1819,6 +1825,7 @@ snapshots: isows@1.0.7(ws@8.21.0): dependencies: ws: 8.21.0 + optional: true js-tokens@10.0.0: {} @@ -1930,6 +1937,7 @@ snapshots: typescript: 7.0.2 transitivePeerDependencies: - zod + optional: true oxlint@1.86.0: optionalDependencies: @@ -2072,6 +2080,7 @@ snapshots: - bufferutil - utf-8-validate - zod + optional: true vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2): dependencies: @@ -2109,6 +2118,7 @@ snapshots: why-is-node-running@3.2.1: {} - ws@8.21.0: {} + ws@8.21.0: + optional: true yallist@5.0.0: {} From 8afefa4c46390eecda880171b4a5b8c9c061d903 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Tue, 6 Oct 2026 18:33:37 +0300 Subject: [PATCH 027/103] ci(ceremony-live): run the live ceremonies without a chain The job becomes "GitHub and X ceremonies (live)" and describes the CCDP ceremony run without a chain. With no chain or indexer images to pull, the stack's start budget is 10 minutes, the test step's 40 (10 + two tests of 12 + 2 to stop) and the job's 50. Triggers, concurrency, the fork and Dependabot skip, the secrets, the scan and the allowlisted upload are unchanged. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 668326af..6895cbfd 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -1,10 +1,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 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. +# Proves a real platform identity from the browser UI through the CCDP +# ceremony: real OAuth sign-in, the real notary and Bridge, and in-browser +# proving. No chain. 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. @@ -20,7 +19,7 @@ on: # 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. +# each run has its own notary and Bridge, and a rate-limited login backs off. concurrency: 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' }} @@ -29,8 +28,8 @@ permissions: contents: read jobs: - bind: - name: GitHub and X bindings (live) + ceremony: + name: GitHub and X ceremonies (live) # A pull request from a fork or from Dependabot gets no secrets, so it # does not run. if: >- @@ -39,7 +38,7 @@ jobs: github.actor != 'dependabot[bot]') runs-on: ubuntu-latest # The test step's budget plus setup, the secret scan and the upload. - timeout-minutes: 55 + timeout-minutes: 50 env: # A missing secret fails the run instead of skipping the test. LIBID_REQUIRE_LIVE: "1" @@ -55,13 +54,13 @@ 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. + # test still runs locally. # 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 + # the stack's 10-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 + - name: Prove GitHub and X identities end to end id: test - timeout-minutes: 45 + timeout-minutes: 40 env: LIBID_LIVE_PLATFORMS: github,x LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log From b802eeab85ccb733be4616097b43d809d1673dc4 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 19 Oct 2026 19:54:53 +0300 Subject: [PATCH 028/103] ci(live-e2e): choose the platforms with a repository variable LIBID_LIVE_PLATFORMS, when set, picks the live ceremonies; unset, GitHub and X run as before. The Google test's account and saved session reach the test step, and the session reaches the secret scan, so a Google run is one variable away. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 6895cbfd..6ebc725d 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -29,7 +29,7 @@ permissions: jobs: ceremony: - name: GitHub and X ceremonies (live) + name: Live ceremonies # A pull request from a fork or from Dependabot gets no secrets, so it # does not run. if: >- @@ -52,17 +52,18 @@ jobs: - name: Install Chromium run: pnpm -C ts --filter @libid/ceremony-live exec playwright install --with-deps chromium - # 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. + # The platforms are the repository variable LIBID_LIVE_PLATFORMS, else + # GitHub and X. Google is left out by default: it has challenged a saved + # session arriving from a runner's address ("Verify it's you"), which no + # automation passes. Its test still runs locally. # Its own timeout, so a run that hangs still reaches the steps below: # the stack's 10-minute start, two tests of 12 minutes, and 2 minutes # to stop the stack (playwright.live.config.ts), with room to spare. - - name: Prove GitHub and X identities end to end + - name: Prove identities end to end id: test timeout-minutes: 40 env: - LIBID_LIVE_PLATFORMS: github,x + LIBID_LIVE_PLATFORMS: ${{ vars.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 }} @@ -72,6 +73,10 @@ jobs: # libid-server-rs' ceremony-tests (branch feat/live-ceremony-tests, # libid-server-rs PR #12). X_TEST_ALICE_COOKIES: ${{ secrets.X_TEST_ALICE_COOKIES }} + GOOGLE_TEST_ALICE_EMAIL: ${{ secrets.GOOGLE_TEST_ALICE_EMAIL }} + # The saved Google session: base64 of a Playwright storage state or + # of `ceremony export google`. + GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} run: pnpm -C ts/apps/ceremony-live test:live # The artifact is public. Screenshots, Playwright's error notes and the @@ -90,6 +95,7 @@ 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 }} + GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} # 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: | From cdc025afdff5cda2d46a3f6ba16874fcc104c6c5 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 19 Oct 2026 20:51:55 +0300 Subject: [PATCH 029/103] ci(live-e2e): prove the Google identity through grndd-testnet's proxy Google challenges a saved session from a runner's address but not from grndd-testnet's NAT address. A google job, on main only, assumes an OIDC role allowed to port-forward to the cluster's Squid proxy, and runs the Google ceremony with LIBID_TEST_PROXY routing the test browser through it. LIBID_TEST_PROXY, unset by default, sends the browser's traffic except loopback through the proxy it names. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 78 +++++++++++++++++++ .../ceremony-live/playwright.live.config.ts | 5 ++ 2 files changed, 83 insertions(+) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 6ebc725d..47429e82 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -115,3 +115,81 @@ jobs: ${{ runner.temp }}/stack-scanned.log retention-days: 14 if-no-files-found: ignore + + # Google challenges a saved session arriving from a runner's address, and not + # from grndd-testnet's NAT address. This job routes the test browser through + # the Squid proxy in that cluster: an OIDC role that only main's workflows + # assume, allowed to port-forward to the proxy and nothing else + # (grndd-systems/deployment, terraform/envs/grndd-testnet/libid-live-e2e.tf). + google: + name: Google ceremony through grndd-testnet (live) + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + id-token: write + env: + LIBID_REQUIRE_LIVE: "1" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-ts + + - name: Install Chromium + run: pnpm -C ts --filter @libid/ceremony-live exec playwright install --with-deps chromium + + - uses: aws-actions/configure-aws-credentials@a03048d87541d1d9fcf2ecf528a4a65ba9bd7838 # v5.0.0 + with: + role-to-assume: arn:aws:iam::849996548040:role/grndd-testnet-libid-live-e2e + aws-region: eu-central-1 + + # The port-forward runs for the rest of the job; the step fails unless + # the proxy answers, and prints the address Google will see. + - name: Reach the Google proxy in grndd-testnet + run: | + aws eks update-kubeconfig --name grndd-testnet --region eu-central-1 + kubectl -n libid-scratch port-forward svc/squid 3128:3128 >"$RUNNER_TEMP/port-forward.log" 2>&1 & + for _ in $(seq 1 30); do + ip=$(curl -fs --max-time 5 -x http://127.0.0.1:3128 https://api.ipify.org) && break + sleep 2 + done + echo "Google sees ${ip:?the proxy did not answer; see $RUNNER_TEMP/port-forward.log}" + + - name: Prove a Google identity end to end + id: test + timeout-minutes: 20 + env: + LIBID_LIVE_PLATFORMS: google + LIBID_TEST_PROXY: http://127.0.0.1:3128 + LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log + GOOGLE_TEST_ALICE_EMAIL: ${{ secrets.GOOGLE_TEST_ALICE_EMAIL }} + GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} + run: pnpm -C ts/apps/ceremony-live test:live + + # As in the ceremony job: nothing goes up unless the scan finds no secret. + - name: Check the failure output for secrets + id: scan + if: ${{ !cancelled() && (steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} + env: + LIBID_REQUIRE_LIVE: "" + GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} + run: | + docker compose ls -q --all --filter name=libid-ceremony-live | + 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/ceremony-live/e2e/scan-secrets.ts ts/apps/ceremony-live/test-results "$RUNNER_TEMP/stack-scanned.log" + + - name: Keep the screenshots and container logs + if: ${{ !cancelled() && steps.scan.outcome == 'success' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: live-e2e-google + path: | + ts/apps/ceremony-live/test-results/**/*.png + ts/apps/ceremony-live/test-results/**/*.md + ${{ runner.temp }}/stack-scanned.log + retention-days: 14 + if-no-files-found: ignore diff --git a/ts/apps/ceremony-live/playwright.live.config.ts b/ts/apps/ceremony-live/playwright.live.config.ts index cd0b76a4..e96d7d86 100644 --- a/ts/apps/ceremony-live/playwright.live.config.ts +++ b/ts/apps/ceremony-live/playwright.live.config.ts @@ -39,6 +39,11 @@ 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'] }, + // LIBID_TEST_PROXY routes the browser's traffic, loopback aside, through a + // proxy: the platforms then see the proxy's address instead of this host's. + ...(process.env.LIBID_TEST_PROXY && { + proxy: { server: process.env.LIBID_TEST_PROXY, bypass: 'localhost,127.0.0.1,[::1]' }, + }), // 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', From 34e32918cc66cc353597f00e38d4a026116ba9a0 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Tue, 20 Oct 2026 13:00:36 +0300 Subject: [PATCH 030/103] ci(live-e2e): port-forward to the Flux-managed google-proxy in namespace libid 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 47429e82..c84ce24c 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -151,7 +151,7 @@ jobs: - name: Reach the Google proxy in grndd-testnet run: | aws eks update-kubeconfig --name grndd-testnet --region eu-central-1 - kubectl -n libid-scratch port-forward svc/squid 3128:3128 >"$RUNNER_TEMP/port-forward.log" 2>&1 & + kubectl -n libid port-forward svc/google-proxy 3128:3128 >"$RUNNER_TEMP/port-forward.log" 2>&1 & for _ in $(seq 1 30); do ip=$(curl -fs --max-time 5 -x http://127.0.0.1:3128 https://api.ipify.org) && break sleep 2 From 09891278cdfb1c46aecb03c99a3f7c0b975e6a90 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Tue, 20 Oct 2026 15:27:44 +0300 Subject: [PATCH 031/103] ci(live-e2e): run the Google job on this repository's pull requests too The grndd-testnet role trusts this workflow's pull-request tokens as well as main's. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index c84ce24c..e7592178 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -118,12 +118,17 @@ jobs: # Google challenges a saved session arriving from a runner's address, and not # from grndd-testnet's NAT address. This job routes the test browser through - # the Squid proxy in that cluster: an OIDC role that only main's workflows - # assume, allowed to port-forward to the proxy and nothing else + # the Squid proxy in that cluster: an OIDC role that this workflow assumes on + # main and on this repository's pull requests, allowed to port-forward to the + # proxy and nothing else # (grndd-systems/deployment, terraform/envs/grndd-testnet/libid-live-e2e.tf). google: name: Google ceremony through grndd-testnet (live) - if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + # A pull request from a fork or from Dependabot gets no OIDC token. + 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 permissions: From 5e2027f91f9becf05abc3cd86e237dfcf768f40b Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Tue, 20 Oct 2026 15:42:07 +0300 Subject: [PATCH 032/103] ci(live-e2e): an opt-in Google job on a self-hosted runner Runs on label libid-google only while the repository variable LIBID_GOOGLE_RUNNER is self-hosted, so a missing runner leaves no queued job. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 65 ++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index e7592178..0862d1ae 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -198,3 +198,68 @@ jobs: ${{ runner.temp }}/stack-scanned.log retention-days: 14 if-no-files-found: ignore + + # The Google ceremony on a self-hosted runner (label libid-google), for a + # runner on a machine whose address Google accepts. Opt-in: it runs only + # while the repository variable LIBID_GOOGLE_RUNNER is `self-hosted`, since a + # job for a missing runner would sit queued. A fork's pull request never + # reaches it. + google-self-hosted: + name: Google ceremony on a self-hosted runner (live) + if: >- + vars.LIBID_GOOGLE_RUNNER == 'self-hosted' && + (github.event_name != 'pull_request' || + (github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]')) + runs-on: [self-hosted, libid-google] + timeout-minutes: 30 + permissions: + contents: read + env: + LIBID_REQUIRE_LIVE: "1" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-ts + + - name: Install Chromium + run: pnpm -C ts --filter @libid/ceremony-live exec playwright install --with-deps chromium + + - name: Show the address Google will see + run: echo "Google sees $(curl -fs --max-time 10 https://api.ipify.org)" + + - name: Prove a Google identity end to end + id: test + timeout-minutes: 20 + env: + LIBID_LIVE_PLATFORMS: google + LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log + GOOGLE_TEST_ALICE_EMAIL: ${{ secrets.GOOGLE_TEST_ALICE_EMAIL }} + GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} + run: pnpm -C ts/apps/ceremony-live test:live + + - name: Check the failure output for secrets + id: scan + if: ${{ !cancelled() && (steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} + env: + LIBID_REQUIRE_LIVE: "" + GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} + run: | + docker compose ls -q --all --filter name=libid-ceremony-live | + 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/ceremony-live/e2e/scan-secrets.ts ts/apps/ceremony-live/test-results "$RUNNER_TEMP/stack-scanned.log" + + - name: Keep the screenshots and container logs + if: ${{ !cancelled() && steps.scan.outcome == 'success' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: live-e2e-google-self-hosted + path: | + ts/apps/ceremony-live/test-results/**/*.png + ts/apps/ceremony-live/test-results/**/*.md + ${{ runner.temp }}/stack-scanned.log + retention-days: 14 + if-no-files-found: ignore From 2167b267afa4a6f9a24d95f0ac216f68c9447861 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Tue, 20 Oct 2026 15:42:17 +0300 Subject: [PATCH 033/103] ci(live-e2e): the cluster Google job runs while LIBID_GOOGLE_RUNNER is cluster One variable picks the Google path (cluster or self-hosted); unset, neither runs, so pushing this before the cluster role exists fails no job. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 0862d1ae..db1af809 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -124,11 +124,13 @@ jobs: # (grndd-systems/deployment, terraform/envs/grndd-testnet/libid-live-e2e.tf). google: name: Google ceremony through grndd-testnet (live) - # A pull request from a fork or from Dependabot gets no OIDC token. + # Runs while the repository variable LIBID_GOOGLE_RUNNER is `cluster`. A + # pull request from a fork or from Dependabot gets no OIDC token. if: >- - github.event_name != 'pull_request' || + vars.LIBID_GOOGLE_RUNNER == 'cluster' && + (github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && - github.actor != 'dependabot[bot]') + github.actor != 'dependabot[bot]')) runs-on: ubuntu-latest timeout-minutes: 30 permissions: From 77d89d8f3b97c14978fe5f8b52d4813832c38e06 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 21 Oct 2026 10:11:56 +0300 Subject: [PATCH 034/103] ci(live-e2e): the self-hosted Google job runs only on dispatch or schedule A pull request never reaches the self-hosted runner. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index db1af809..2e250fe6 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -204,15 +204,14 @@ jobs: # The Google ceremony on a self-hosted runner (label libid-google), for a # runner on a machine whose address Google accepts. Opt-in: it runs only # while the repository variable LIBID_GOOGLE_RUNNER is `self-hosted`, since a - # job for a missing runner would sit queued. A fork's pull request never - # reaches it. + # job for a missing runner would sit queued. It never runs for a pull + # request: only a manual dispatch, which needs write access, or the + # schedule, which runs main. google-self-hosted: name: Google ceremony on a self-hosted runner (live) if: >- vars.LIBID_GOOGLE_RUNNER == 'self-hosted' && - (github.event_name != 'pull_request' || - (github.event.pull_request.head.repo.full_name == github.repository && - github.actor != 'dependabot[bot]')) + (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule') runs-on: [self-hosted, libid-google] timeout-minutes: 30 permissions: From 5bd1a7c62cd140528ca1f8b2c760af1c8b91092e Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Wed, 21 Oct 2026 18:07:39 +0300 Subject: [PATCH 035/103] ci(live-e2e): the cluster Google job runs on main only, through namespace libid-e2e The deployment's role now trusts only live-e2e.yml on main, and the proxy moved to its own namespace. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 2e250fe6..40938096 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -118,19 +118,17 @@ jobs: # Google challenges a saved session arriving from a runner's address, and not # from grndd-testnet's NAT address. This job routes the test browser through - # the Squid proxy in that cluster: an OIDC role that this workflow assumes on - # main and on this repository's pull requests, allowed to port-forward to the - # proxy and nothing else + # the Squid proxy in that cluster: an OIDC role that only this workflow on + # main assumes, allowed to port-forward to the proxy in namespace libid-e2e # (grndd-systems/deployment, terraform/envs/grndd-testnet/libid-live-e2e.tf). google: name: Google ceremony through grndd-testnet (live) - # Runs while the repository variable LIBID_GOOGLE_RUNNER is `cluster`. A - # pull request from a fork or from Dependabot gets no OIDC token. + # Runs while the repository variable LIBID_GOOGLE_RUNNER is `cluster`, and + # only on main: the role trusts nothing else. if: >- vars.LIBID_GOOGLE_RUNNER == 'cluster' && - (github.event_name != 'pull_request' || - (github.event.pull_request.head.repo.full_name == github.repository && - github.actor != 'dependabot[bot]')) + github.event_name != 'pull_request' && + github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -158,7 +156,7 @@ jobs: - name: Reach the Google proxy in grndd-testnet run: | aws eks update-kubeconfig --name grndd-testnet --region eu-central-1 - kubectl -n libid port-forward svc/google-proxy 3128:3128 >"$RUNNER_TEMP/port-forward.log" 2>&1 & + kubectl -n libid-e2e port-forward svc/google-proxy 3128:3128 >"$RUNNER_TEMP/port-forward.log" 2>&1 & for _ in $(seq 1 30); do ip=$(curl -fs --max-time 5 -x http://127.0.0.1:3128 https://api.ipify.org) && break sleep 2 From ceb40612bd50789891edbfa19dcfcabf648723c8 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 17:36:33 +0300 Subject: [PATCH 036/103] ci(live-e2e): a Google job through a proxy reached over SSM Opt-in with LIBID_GOOGLE_RUNNER=ssm. The job assumes LIBID_GOOGLE_PROXY_ROLE by OIDC and port-forwards to Squid on LIBID_GOOGLE_PROXY_INSTANCE, an EC2 instance with no inbound port, so Google sees that instance's address. It runs on a dispatch or the schedule. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 86 ++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 40938096..10fb144b 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -199,6 +199,92 @@ jobs: retention-days: 14 if-no-files-found: ignore + google-ssm: + name: Google ceremony through an SSM-reached proxy (live) + # Runs while the repository variable LIBID_GOOGLE_RUNNER is `ssm`, on a + # dispatch or the schedule. The proxy is an EC2 instance with no inbound + # port: LIBID_GOOGLE_PROXY_ROLE may only open an SSM port-forward to + # LIBID_GOOGLE_PROXY_INSTANCE. + if: >- + vars.LIBID_GOOGLE_RUNNER == 'ssm' && + (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule') + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + id-token: write + env: + LIBID_REQUIRE_LIVE: "1" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-ts + + - name: Install Chromium + run: pnpm -C ts --filter @libid/ceremony-live exec playwright install --with-deps chromium + + - uses: aws-actions/configure-aws-credentials@a03048d87541d1d9fcf2ecf528a4a65ba9bd7838 # v5.0.0 + with: + role-to-assume: ${{ vars.LIBID_GOOGLE_PROXY_ROLE }} + aws-region: eu-central-1 + + # The port-forward runs for the rest of the job; the step fails unless + # the proxy answers, and prints the address Google will see. + - name: Reach the Google proxy over SSM + env: + INSTANCE: ${{ vars.LIBID_GOOGLE_PROXY_INSTANCE }} + run: | + if ! command -v session-manager-plugin >/dev/null; then + curl -fsSLo "$RUNNER_TEMP/smp.deb" https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb + sudo dpkg -i "$RUNNER_TEMP/smp.deb" + fi + aws ssm start-session --target "$INSTANCE" \ + --document-name AWS-StartPortForwardingSession \ + --parameters portNumber=3128,localPortNumber=3128 >"$RUNNER_TEMP/port-forward.log" 2>&1 & + for _ in $(seq 1 30); do + ip=$(curl -fs --max-time 5 -x http://127.0.0.1:3128 https://api.ipify.org) && break + sleep 2 + done + echo "Google sees ${ip:?the proxy did not answer; see $RUNNER_TEMP/port-forward.log}" + + - name: Prove a Google identity end to end + id: test + timeout-minutes: 20 + env: + LIBID_LIVE_PLATFORMS: google + LIBID_TEST_PROXY: http://127.0.0.1:3128 + LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log + GOOGLE_TEST_ALICE_EMAIL: ${{ secrets.GOOGLE_TEST_ALICE_EMAIL }} + GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} + run: pnpm -C ts/apps/ceremony-live test:live + + # As in the ceremony job: nothing goes up unless the scan finds no secret. + - name: Check the failure output for secrets + id: scan + if: ${{ !cancelled() && (steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} + env: + LIBID_REQUIRE_LIVE: "" + GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} + run: | + docker compose ls -q --all --filter name=libid-ceremony-live | + 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/ceremony-live/e2e/scan-secrets.ts ts/apps/ceremony-live/test-results "$RUNNER_TEMP/stack-scanned.log" + + - name: Keep the screenshots and container logs + if: ${{ !cancelled() && steps.scan.outcome == 'success' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: live-e2e-google-ssm + path: | + ts/apps/ceremony-live/test-results/**/*.png + ts/apps/ceremony-live/test-results/**/*.md + ${{ runner.temp }}/stack-scanned.log + retention-days: 14 + if-no-files-found: ignore + # The Google ceremony on a self-hosted runner (label libid-google), for a # runner on a machine whose address Google accepts. Opt-in: it runs only # while the repository variable LIBID_GOOGLE_RUNNER is `self-hosted`, since a From 35109ea08ad09a762a345614cc4d95a1a89b9d85 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 18:59:31 +0300 Subject: [PATCH 037/103] ci(live-e2e): TEMPORARY run the SSM Google job on this repository's pull requests Lets PR #72 test the route before merge; reverted before merge, with the proxy role's pull-request trust. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 10fb144b..b22ca684 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -207,7 +207,9 @@ jobs: # LIBID_GOOGLE_PROXY_INSTANCE. if: >- vars.LIBID_GOOGLE_RUNNER == 'ssm' && - (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule') + (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository)) runs-on: ubuntu-latest timeout-minutes: 30 permissions: From d3d0f3579dd3c7a70d99f88e63cc468ce36881e9 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 19:24:52 +0300 Subject: [PATCH 038/103] fix(ceremony-live): the operation for a chain-free digest LedgerId only routes the notary; the operation domain is libid.bind-identity and the transaction data abi.encode(target), as libID-contracts 0.19 has them. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/ceremony-live/src/operation.ts | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/ts/apps/ceremony-live/src/operation.ts b/ts/apps/ceremony-live/src/operation.ts index 06cd75e7..d1997ccd 100644 --- a/ts/apps/ceremony-live/src/operation.ts +++ b/ts/apps/ceremony-live/src/operation.ts @@ -1,26 +1,21 @@ -// The operation a ceremony is started for. A ceremony binds its proof to a -// ledger, an operation domain and transaction data; these are fixed test -// values shaped as an EVM `bind` would supply them, so the proof is -// well-formed. Nothing verifies it on a chain. +// The operation a ceremony is started for. A ceremony binds its proof to an +// operation domain and transaction data; these are fixed test values shaped +// as an EVM `bind` would supply them, so the proof is well-formed. Nothing +// verifies it on a chain. import type { LedgerId } from '@libid/ledger' import { keccak_256 } from '@noble/hashes/sha3.js' import { hexToBytes } from '@noble/hashes/utils.js' -/** The EVM ledger of chain 31337, anvil's default id. */ -const CHAIN_ID = 31337n - /** A fixed holder for the transaction data; no account behind it is used. */ const HOLDER = '1111111111111111111111111111111111111111' /** An EVM ledger routed through `notaryUrl`. */ export function evmLedger(notaryUrl: string): LedgerId { - // specs/chain-profiles.md §3.1: keccak256 of the 32-byte big-endian EIP-155 id. - const hash = keccak_256(hexToBytes(CHAIN_ID.toString(16).padStart(64, '0'))) - return { hash: () => hash.slice(), notaryAddress: () => notaryUrl } + return { notaryAddress: () => notaryUrl } } -/** IdentityRegistry's OPERATION_DOMAIN: keccak256("libid.claim-identity"). */ -export const operationDomain = keccak_256(new TextEncoder().encode('libid.claim-identity')) +/** IdentityRegistry's OPERATION_DOMAIN: keccak256("libid.bind-identity"). */ +export const operationDomain = keccak_256(new TextEncoder().encode('libid.bind-identity')) -/** `bind`'s Authorized Transaction Data: abi.encode(HOLDER, uint256 0, address 0). */ -export const transactionData = hexToBytes(`${'0'.repeat(24)}${HOLDER}${'0'.repeat(128)}`) +/** `bind`'s Authorized Transaction Data: abi.encode(HOLDER). */ +export const transactionData = hexToBytes(`${'0'.repeat(24)}${HOLDER}`) From 1a5ceebe00a05ae509a3f97bc4fab19aa46860a4 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 20:14:30 +0300 Subject: [PATCH 039/103] test(dev): run the live ceremony specs against the dev application The live specs drove a fork of the dev application with its own stack, compose file and Bridge configuration, which had drifted from main: its notary was 0.4.0 against main's 0.5.0 bundle, and its spec still expected a hashed Google id. They now run against @libid/dev itself, through a second Playwright config (playwright.live.config.ts, testDir e2e) beside the mock suite (testDir src), so the services, ports and images are the ones developers use. The spec starts each ceremony from dev's platform anchor and reads the run's entry in window.results: accepted, the platform, the raw account id, the handle, 32-byte id and handle nodes, and a non-empty proof. services.ts gains LIBID_STACK_LOGS: when set, every child's output, containers included, goes to that file instead of the terminal, so CI can keep it off the public job log and scan it before upload. The fork (stack.ts, compose.yaml, bridge-config.toml, its app and package) is removed; stealth.js moves unchanged. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/ceremony-live/.gitignore | 2 - ts/apps/ceremony-live/bridge-config.toml | 15 -- ts/apps/ceremony-live/compose.yaml | 55 ---- ts/apps/ceremony-live/local.ts | 16 -- ts/apps/ceremony-live/package.json | 29 --- ts/apps/ceremony-live/src/app.ts | 108 -------- ts/apps/ceremony-live/src/index.html | 43 ---- ts/apps/ceremony-live/src/operation.ts | 21 -- ts/apps/ceremony-live/stack.ts | 236 ------------------ ts/apps/ceremony-live/tsconfig.json | 10 - ts/apps/ceremony-live/vite.config.ts | 18 -- .../e2e/ceremony.live.spec.ts | 75 ++++-- ts/apps/{ceremony-live => dev}/e2e/github.ts | 0 ts/apps/{ceremony-live => dev}/e2e/google.ts | 0 ts/apps/{ceremony-live => dev}/e2e/live.ts | 11 +- ts/apps/{ceremony-live => dev}/e2e/person.ts | 0 ts/apps/{ceremony-live => dev}/e2e/popup.ts | 5 +- .../e2e/scan-secrets.ts | 0 ts/apps/{ceremony-live => dev}/e2e/session.ts | 0 ts/apps/{ceremony-live => dev}/e2e/stealth.js | 0 ts/apps/{ceremony-live => dev}/e2e/x.ts | 0 ts/apps/dev/package.json | 4 +- .../playwright.live.config.ts | 12 +- ts/apps/dev/services.ts | 13 +- ts/apps/dev/tsconfig.json | 2 +- ts/biome.json | 3 +- ts/pnpm-lock.yaml | 30 +-- 27 files changed, 83 insertions(+), 625 deletions(-) delete mode 100644 ts/apps/ceremony-live/.gitignore delete mode 100644 ts/apps/ceremony-live/bridge-config.toml delete mode 100644 ts/apps/ceremony-live/compose.yaml delete mode 100644 ts/apps/ceremony-live/local.ts delete mode 100644 ts/apps/ceremony-live/package.json delete mode 100644 ts/apps/ceremony-live/src/app.ts delete mode 100644 ts/apps/ceremony-live/src/index.html delete mode 100644 ts/apps/ceremony-live/src/operation.ts delete mode 100644 ts/apps/ceremony-live/stack.ts delete mode 100644 ts/apps/ceremony-live/tsconfig.json delete mode 100644 ts/apps/ceremony-live/vite.config.ts rename ts/apps/{ceremony-live => dev}/e2e/ceremony.live.spec.ts (50%) rename ts/apps/{ceremony-live => dev}/e2e/github.ts (100%) rename ts/apps/{ceremony-live => dev}/e2e/google.ts (100%) rename ts/apps/{ceremony-live => dev}/e2e/live.ts (50%) rename ts/apps/{ceremony-live => dev}/e2e/person.ts (100%) rename ts/apps/{ceremony-live => dev}/e2e/popup.ts (95%) rename ts/apps/{ceremony-live => dev}/e2e/scan-secrets.ts (100%) rename ts/apps/{ceremony-live => dev}/e2e/session.ts (100%) rename ts/apps/{ceremony-live => dev}/e2e/stealth.js (100%) rename ts/apps/{ceremony-live => dev}/e2e/x.ts (100%) rename ts/apps/{ceremony-live => dev}/playwright.live.config.ts (83%) diff --git a/ts/apps/ceremony-live/.gitignore b/ts/apps/ceremony-live/.gitignore deleted file mode 100644 index d40c2ab7..00000000 --- a/ts/apps/ceremony-live/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.cache/ -test-results/ diff --git a/ts/apps/ceremony-live/bridge-config.toml b/ts/apps/ceremony-live/bridge-config.toml deleted file mode 100644 index 180cee76..00000000 --- a/ts/apps/ceremony-live/bridge-config.toml +++ /dev/null @@ -1,15 +0,0 @@ -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/ceremony-live/compose.yaml b/ts/apps/ceremony-live/compose.yaml deleted file mode 100644 index 72509e87..00000000 --- a/ts/apps/ceremony-live/compose.yaml +++ /dev/null @@ -1,55 +0,0 @@ -# Started by stack.ts after building CCDP: the notary, Bridge and the CCDP -# static server, everything a ceremony needs. No chain. -services: - 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: "" - # A public test key, never a production notary identity. Nothing here - # verifies the attestations it signs on a chain. - 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 - -configs: - bridge: - file: ./bridge-config.toml diff --git a/ts/apps/ceremony-live/local.ts b/ts/apps/ceremony-live/local.ts deleted file mode 100644 index b20c8a25..00000000 --- a/ts/apps/ceremony-live/local.ts +++ /dev/null @@ -1,16 +0,0 @@ -// The local stack's ports, for the stack, the app and the tests. -// compose.yaml and bridge-config.toml repeat what they need. - -/** The 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' - -export type Platform = 'github' | 'x' | 'google' - -/** The platforms a live run proves: LIBID_LIVE_PLATFORMS, comma-separated, or all three. */ -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/ceremony-live/package.json b/ts/apps/ceremony-live/package.json deleted file mode 100644 index d2173c47..00000000 --- a/ts/apps/ceremony-live/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@libid/ceremony-live", - "version": "0.0.0", - "private": true, - "type": "module", - "description": "An application that runs the CCDP ceremony for a platform identity in the browser, live.", - "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/ceremony-live^...' build", - "build": "vite build", - "typecheck": "tsc --noEmit", - "test:live": "playwright test -c playwright.live.config.ts" - }, - "dependencies": { - "@libid/ceremony": "workspace:^", - "@libid/ledger": "workspace:^", - "@libid/popup": "workspace:^", - "@noble/hashes": "^2.4.0" - }, - "devDependencies": { - "@playwright/test": "catalog:", - "@types/node": "catalog:", - "typescript": "catalog:", - "vite": "catalog:", - "otpauth": "^9.4.0" - } -} diff --git a/ts/apps/ceremony-live/src/app.ts b/ts/apps/ceremony-live/src/app.ts deleted file mode 100644 index f41794be..00000000 --- a/ts/apps/ceremony-live/src/app.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { - type CCDPClient, - type Ceremony, - CeremonyStage, - createCCDPClient, - type IdentityResult, -} from '@libid/ceremony' -import { PopupWindow } from '@libid/popup' -import { BRIDGE, NOTARY, type Platform } from '../local.ts' -import { evmLedger, operationDomain, transactionData } from './operation.ts' - -const ledger = evmLedger(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 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 names: Record = { github: 'GitHub', x: 'X', google: 'Google' } -const platforms = Object.keys(names) as Platform[] -const anchors = Object.fromEntries( - platforms.map((platform) => [ - platform, - document.querySelector(`#prove-${platform}`)!, - ]), -) as Record - -type Accepted = Extract, { status: 'accepted' }> - -/** - * The proof's notary attestations and the length of its ZK proof: GitHub and X - * carry a token and an identity attestation beside the bearer-link proof; - * Google's ID-token proof needs none. - */ -function summary({ oauthProof: { proof } }: Accepted): { attestations: string; bytes: number } { - if ('identityProof' in proof) return { attestations: 'none', bytes: proof.identityProof.length } - return { attestations: 'token, identity', bytes: proof.bearerLinkProof.length } -} - -function showResult(result: Accepted) { - const { identity, oauthProof } = result - show('user-name', identity.userName) - show('user-id', identity.userId) - show('platform', identity.platformId) - show('ceremony-version', String(oauthProof.platformCeremonyVersion)) - const { attestations, bytes } = summary(result) - show('attestations', attestations) - show('proof-bytes', String(bytes)) - show('expires-at', String(oauthProof.expiresAt)) - status('Proved.') - show('outcome', 'proved') -} - -/** Start a ceremony for `platform` from its anchor's click, then show what it proves. */ -function launch(client: CCDPClient, platform: Platform, event: MouseEvent) { - // Window creation and the run's start stay synchronous with the click. - const connection = client.connect(PopupWindow.fromAnchor(event)) - let ceremony: Ceremony - try { - ceremony = client.new(connection, platform, ledger, operationDomain, transactionData) - } catch (error) { - event.preventDefault() - connection.close() - fail(messageOf(error, 'Unable to start the ceremony.')) - return - } - const off = ceremony.onStage((update) => { - show( - 'stage', - update.status === 'active' - ? CeremonyStage.message(update.stage, names[platform]) - : update.status, - ) - }) - void ceremony - .proveUserIdentity() - .then((result) => { - if (result.status !== 'accepted') - throw new Error(`${names[platform]} denied the authorization.`) - showResult(result) - }) - .catch((error: unknown) => fail(messageOf(error, 'The ceremony failed.'))) - .finally(() => { - connection.close() - off() - }) -} - -async function initialize() { - const client = await createCCDPClient({ oauthBridge: BRIDGE }) - for (const platform of platforms.filter((p) => client.enabledPlatforms.includes(p))) { - anchors[platform].setAttribute('aria-disabled', 'false') - anchors[platform].addEventListener('click', (event) => launch(client, platform, event)) - } - status('Choose a platform to prove.') -} - -void initialize().catch((error: unknown) => - status(`Could not start: ${messageOf(error, String(error))}. Is the stack running?`), -) diff --git a/ts/apps/ceremony-live/src/index.html b/ts/apps/ceremony-live/src/index.html deleted file mode 100644 index 10ca9a8e..00000000 --- a/ts/apps/ceremony-live/src/index.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - Prove an identity - - - -

Prove an identity

-

Prove a GitHub, X or Google account in the browser through the CCDP ceremony. Nothing is sent to a chain.

- -

-
-
Stage
—
-
Account
—
-
User ID
—
-
Platform
—
-
Ceremony version
—
-
Attestations
—
-
Proof bytes
—
-
Expires at
—
-
Outcome
—
-
- - - diff --git a/ts/apps/ceremony-live/src/operation.ts b/ts/apps/ceremony-live/src/operation.ts deleted file mode 100644 index d1997ccd..00000000 --- a/ts/apps/ceremony-live/src/operation.ts +++ /dev/null @@ -1,21 +0,0 @@ -// The operation a ceremony is started for. A ceremony binds its proof to an -// operation domain and transaction data; these are fixed test values shaped -// as an EVM `bind` would supply them, so the proof is well-formed. Nothing -// verifies it on a chain. -import type { LedgerId } from '@libid/ledger' -import { keccak_256 } from '@noble/hashes/sha3.js' -import { hexToBytes } from '@noble/hashes/utils.js' - -/** A fixed holder for the transaction data; no account behind it is used. */ -const HOLDER = '1111111111111111111111111111111111111111' - -/** An EVM ledger routed through `notaryUrl`. */ -export function evmLedger(notaryUrl: string): LedgerId { - return { notaryAddress: () => notaryUrl } -} - -/** IdentityRegistry's OPERATION_DOMAIN: keccak256("libid.bind-identity"). */ -export const operationDomain = keccak_256(new TextEncoder().encode('libid.bind-identity')) - -/** `bind`'s Authorized Transaction Data: abi.encode(HOLDER). */ -export const transactionData = hexToBytes(`${'0'.repeat(24)}${HOLDER}`) diff --git a/ts/apps/ceremony-live/stack.ts b/ts/apps/ceremony-live/stack.ts deleted file mode 100644 index 7be430d4..00000000 --- a/ts/apps/ceremony-live/stack.ts +++ /dev/null @@ -1,236 +0,0 @@ -// Start the notary, Bridge and CCDP; with --app, also the ceremony app. No -// OAuth mocks and no chain. -// -// 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 { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' -import { 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' -import { fileURLToPath } from 'node:url' -import { createServer as createViteServer, type ViteDevServer } from 'vite' -import { APP_ORIGIN, BRIDGE, NOTARY } from './local.ts' - -const root = fileURLToPath(new URL('.', import.meta.url)) -const cache = join(root, '.cache') -const project = `libid-ceremony-live-${createHash('sha256').update(root).digest('hex').slice(0, 12)}` -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_ON_HOST = BRIDGE.replace('localhost', '127.0.0.1') -/** CCDP's static server, published beside Bridge (compose.yaml). */ -const CCDP = 'http://127.0.0.1:4683' - -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' - -/** 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, { 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}`)), - ) - }) -} - -/** 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) { - signal.throwIfAborted() - try { - if (await check()) return - } catch { - /* not up yet */ - } - 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 -} - -/** 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, the readiness check could pass against that stack and the - * tests would run against it. - */ -async function portsFree() { - const ports = [NOTARY, BRIDGE, CCDP].map((url) => Number(new URL(url).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).`, - ) -} - -/** 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 */ - } -} - -/** 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) -} - -// ─── Main ─────────────────────────────────────────────────────── - -try { - takeLock() -} catch (error) { - fail(error) -} -// CCDP builds while Docker clears a stack this checkout left behind, so every -// run starts fresh; a port still taken then belongs to another stack. The -// images come down before anything is timed. -const ccdp = run('pnpm', [ - '--filter', - '@libid/ceremony', - 'build:ccdp-artifacts', - '--out-dir', - join(cache, 'ccdp'), -]) -ccdp.catch(() => {}) -try { - await Promise.all([ - run('docker', [...composeArgs, 'down', '--volumes', '--remove-orphans']), - run('docker', [...composeArgs, 'pull', '--quiet']), - ]) -} catch { - fail( - 'Could not run Docker Compose or pull its images. Install Docker with Compose and start its engine.', - ) -} -try { - await ccdp - await portsFree() -} catch (error) { - fail(error) -} - -let frontend: ViteDevServer | undefined -/** Stop what this process started: the frontend, then its own Compose project. */ -function stop(code: number) { - if (signal.aborted) return - stopping.abort() - 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: childOutput }) - down.on('error', () => console.error('Could not stop the stack containers. Check Docker.')) - 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: childOutput, - 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) => stop(code || 1)) - -try { - await until('Bridge', () => - answers(`${BRIDGE_ON_HOST}/api/v1/ceremony/config`, { - headers: { Origin: APP_ORIGIN }, - redirect: 'error', - }), - ) - signal.throwIfAborted() - console.info(`Stack ready: Bridge ${BRIDGE}, notary ${NOTARY}`) - 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 (!signal.aborted) { - console.error(error instanceof Error ? error.message : error) - stop(1) - } -} diff --git a/ts/apps/ceremony-live/tsconfig.json b/ts/apps/ceremony-live/tsconfig.json deleted file mode 100644 index a3090e41..00000000 --- a/ts/apps/ceremony-live/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "noEmit": true, - "allowImportingTsExtensions": true, - "types": ["node", "vite/client"] - }, - "include": ["*.ts", "src", "e2e"] -} diff --git a/ts/apps/ceremony-live/vite.config.ts b/ts/apps/ceremony-live/vite.config.ts deleted file mode 100644 index 90add309..00000000 --- a/ts/apps/ceremony-live/vite.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { defineConfig } from 'vite' -import { APP_PORT } from './local.ts' - -const root = fileURLToPath(new URL('.', import.meta.url)) -const directory = join(root, '.cache/app') -export default defineConfig({ - root: join(root, 'src'), - cacheDir: join(directory, 'vite'), - envDir: root, - server: { - host: 'localhost', - port: APP_PORT, - strictPort: true, - }, - build: { outDir: join(directory, 'app'), emptyOutDir: true }, -}) diff --git a/ts/apps/ceremony-live/e2e/ceremony.live.spec.ts b/ts/apps/dev/e2e/ceremony.live.spec.ts similarity index 50% rename from ts/apps/ceremony-live/e2e/ceremony.live.spec.ts rename to ts/apps/dev/e2e/ceremony.live.spec.ts index 168ec24b..9c6c24b9 100644 --- a/ts/apps/ceremony-live/e2e/ceremony.live.spec.ts +++ b/ts/apps/dev/e2e/ceremony.live.spec.ts @@ -1,3 +1,4 @@ +import type { PlatformId } from '@libid/ceremony' import { type BrowserContext, expect, type Page, test } from '@playwright/test' import { authorizeOnGitHub, githubAccount } from './github.ts' import { authorizeOnGoogle } from './google.ts' @@ -11,17 +12,23 @@ const OUTCOME_TIMEOUT = 8 * 60_000 type Name = 'GitHub' | 'X' | 'Google' -const expected: Record = { - GitHub: { platform: 'github', userId: /^\d+$/ }, - X: { platform: 'x', userId: /^\d+$/ }, - // The token's `sub`, hashed: no Google account id reaches the page. - Google: { platform: 'google', userId: /^0x[0-9a-f]{64}$/ }, +const platformOf: Record = { GitHub: 'github', X: 'x', Google: 'google' } + +/** What the test reads of a run's entry in window.results. */ +interface Outcome { + status: string + platformId?: string + userId?: string + userName?: string + idNodeBytes?: number + handleNodeBytes?: number + proofPresent?: boolean } /** - * Start the platform's ceremony from its link, let `authorize` drive the popup - * through the platform, then check what the page reports: the proved account, - * its id's shape, the ceremony and a non-empty, unexpired proof. + * Start the platform's ceremony from its anchor, let `authorize` drive the + * popup through the platform, then check the run's result: accepted, the + * proved account with its id and handle nodes, and a non-empty proof. */ async function proveThroughTheUi( page: Page, @@ -31,26 +38,48 @@ async function proveThroughTheUi( authorize: (popup: Page) => Promise, ) { await page.goto('/') - await expect(page.getByTestId('status')).toHaveText('Choose a platform to prove.') + await expect(page.locator('#status')).toHaveText('Ready. Click a platform to start a ceremony.') const [popup] = await Promise.all([ context.waitForEvent('page'), - page.getByRole('link', { name: `Prove ${name}` }).click(), + page.getByRole('button', { name, exact: true }).click(), ]) + const id = await page.locator('#history tr').first().getAttribute('data-ceremony-id') + expect(id).toBeTruthy() await authorize(popup) - // 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('proved') - await expect(page.getByTestId('user-name')).toHaveText(expectedUser, { ignoreCase: true }) - await expect(page.getByTestId('user-id')).toHaveText(expected[name].userId) - await expect(page.getByTestId('platform')).toHaveText(expected[name].platform) - await expect(page.getByTestId('ceremony-version')).toHaveText(/^[1-9]\d*$/) - await expect(page.getByTestId('attestations')).not.toHaveText('—') - expect(Number(await page.getByTestId('proof-bytes').textContent())).toBeGreaterThan(0) - expect(Number(await page.getByTestId('expires-at').textContent())).toBeGreaterThan( - Date.now() / 1000, - ) + await expect + .poll(() => page.evaluate((id) => window.results.has(id), id!), { timeout: OUTCOME_TIMEOUT }) + .toBe(true) + const outcome = await page.evaluate((id): Outcome => { + const result = window.results.get(id)! + if (result.status !== 'accepted') return { status: result.status } + const { identity, oauthProof } = result + return { + status: result.status, + platformId: identity.platformId, + userId: identity.userId, + userName: identity.userName, + idNodeBytes: identity.idNode.length, + handleNodeBytes: identity.handleNode.length, + proofPresent: Object.values(oauthProof.proof).some( + (value) => value instanceof Uint8Array && value.length > 0, + ), + } + }, id!) + // A failed run's row says why. + const message = await page + .locator(`#history tr[data-ceremony-id="${id}"] .run-status`) + .textContent() + expect(outcome.status, message ?? undefined).toBe('accepted') + expect(outcome.platformId).toBe(platformOf[name]) + // The account id as the platform sent it: GitHub's and X's are decimal. + if (name === 'Google') expect(outcome.userId).not.toMatch(/^0x/) + else expect(outcome.userId).toMatch(/^\d+$/) + expect(outcome.userId).toBeTruthy() + expect(outcome.userName?.toLowerCase()).toBe(expectedUser.toLowerCase()) + expect(outcome.idNodeBytes).toBe(32) + expect(outcome.handleNodeBytes).toBe(32) + expect(outcome.proofPresent).toBe(true) } test('a GitHub identity is proved in the browser', async ({ page, context }) => { diff --git a/ts/apps/ceremony-live/e2e/github.ts b/ts/apps/dev/e2e/github.ts similarity index 100% rename from ts/apps/ceremony-live/e2e/github.ts rename to ts/apps/dev/e2e/github.ts diff --git a/ts/apps/ceremony-live/e2e/google.ts b/ts/apps/dev/e2e/google.ts similarity index 100% rename from ts/apps/ceremony-live/e2e/google.ts rename to ts/apps/dev/e2e/google.ts diff --git a/ts/apps/ceremony-live/e2e/live.ts b/ts/apps/dev/e2e/live.ts similarity index 50% rename from ts/apps/ceremony-live/e2e/live.ts rename to ts/apps/dev/e2e/live.ts index b313b3cf..f2dae59e 100644 --- a/ts/apps/ceremony-live/e2e/live.ts +++ b/ts/apps/dev/e2e/live.ts @@ -1,10 +1,13 @@ -import { livePlatforms, type Platform } from '../local.ts' +import type { PlatformId } from '@libid/ceremony' /** - * Whether this run proves `platform`. A platform left out of - * LIBID_LIVE_PLATFORMS is skipped on purpose, and reported as skipped. + * Whether this run proves `platform`: LIBID_LIVE_PLATFORMS, comma-separated, + * or all three. A platform left out is skipped on purpose, and reported as skipped. */ -export const isLive = (platform: Platform) => livePlatforms(process.env).includes(platform) +export function isLive(platform: PlatformId): boolean { + const listed = process.env.LIBID_LIVE_PLATFORMS?.split(',').map((name) => name.trim()) + return (listed ?? ['github', 'x', 'google']).includes(platform) +} /** * A test secret from the environment, or undefined, which skips the test diff --git a/ts/apps/ceremony-live/e2e/person.ts b/ts/apps/dev/e2e/person.ts similarity index 100% rename from ts/apps/ceremony-live/e2e/person.ts rename to ts/apps/dev/e2e/person.ts diff --git a/ts/apps/ceremony-live/e2e/popup.ts b/ts/apps/dev/e2e/popup.ts similarity index 95% rename from ts/apps/ceremony-live/e2e/popup.ts rename to ts/apps/dev/e2e/popup.ts index 5d3987c0..bdd98383 100644 --- a/ts/apps/ceremony-live/e2e/popup.ts +++ b/ts/apps/dev/e2e/popup.ts @@ -1,11 +1,10 @@ import { setTimeout as sleep } from 'node:timers/promises' import type { Page, Request } from '@playwright/test' -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) +/** Where every platform returns the popup: the local Bridge's OAuth callback. */ +const CALLBACK = new URL('http://localhost:4682/auth/callback') /** Wait `ms` inside the driver's budget; reject with `reason` if the wait would pass it. */ export type Pause = (ms: number, reason: string) => Promise diff --git a/ts/apps/ceremony-live/e2e/scan-secrets.ts b/ts/apps/dev/e2e/scan-secrets.ts similarity index 100% rename from ts/apps/ceremony-live/e2e/scan-secrets.ts rename to ts/apps/dev/e2e/scan-secrets.ts diff --git a/ts/apps/ceremony-live/e2e/session.ts b/ts/apps/dev/e2e/session.ts similarity index 100% rename from ts/apps/ceremony-live/e2e/session.ts rename to ts/apps/dev/e2e/session.ts diff --git a/ts/apps/ceremony-live/e2e/stealth.js b/ts/apps/dev/e2e/stealth.js similarity index 100% rename from ts/apps/ceremony-live/e2e/stealth.js rename to ts/apps/dev/e2e/stealth.js diff --git a/ts/apps/ceremony-live/e2e/x.ts b/ts/apps/dev/e2e/x.ts similarity index 100% rename from ts/apps/ceremony-live/e2e/x.ts rename to ts/apps/dev/e2e/x.ts diff --git a/ts/apps/dev/package.json b/ts/apps/dev/package.json index f3647ab0..14c5cbde 100644 --- a/ts/apps/dev/package.json +++ b/ts/apps/dev/package.json @@ -13,7 +13,8 @@ "build:deps": "pnpm --filter '@libid/dev^...' build", "build": "vite build", "typecheck": "tsc --noEmit", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:live": "playwright test -c playwright.live.config.ts" }, "dependencies": { "@libid/ceremony": "workspace:^", @@ -24,6 +25,7 @@ "devDependencies": { "@playwright/test": "catalog:", "@types/node": "catalog:", + "otpauth": "^9.4.0", "typescript": "catalog:", "vite": "catalog:" } diff --git a/ts/apps/ceremony-live/playwright.live.config.ts b/ts/apps/dev/playwright.live.config.ts similarity index 83% rename from ts/apps/ceremony-live/playwright.live.config.ts rename to ts/apps/dev/playwright.live.config.ts index e96d7d86..f9197bb8 100644 --- a/ts/apps/ceremony-live/playwright.live.config.ts +++ b/ts/apps/dev/playwright.live.config.ts @@ -2,7 +2,6 @@ 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. @@ -13,6 +12,9 @@ if (envFile) { process.env[name] ??= value } +/** dev's application and services (services.ts, vite.config.ts). */ +const APP_ORIGIN = 'http://localhost:4691' + /** Live: real platforms, real notary, real Bridge; no chain. */ export default defineConfig({ testDir: 'e2e', @@ -21,16 +23,16 @@ export default defineConfig({ // 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. + // One command: start the services and the app, test, then stop both. A + // `pnpm dev` already running locally is reused. webServer: { command: 'pnpm dev', url: APP_ORIGIN, reuseExistingServer: !process.env.CI, timeout: 10 * 60_000, gracefulShutdown: { signal: 'SIGTERM', timeout: 120_000 }, - // CI keeps the stack's output off the public job log: the stack writes - // it to LIBID_STACK_LOGS, which is scanned before upload. + // CI keeps the services' output off the public job log: services.ts + // writes it to LIBID_STACK_LOGS, which is scanned before upload. stdout: process.env.CI ? 'ignore' : 'pipe', }, use: { diff --git a/ts/apps/dev/services.ts b/ts/apps/dev/services.ts index 682fe3c3..b312b70e 100644 --- a/ts/apps/dev/services.ts +++ b/ts/apps/dev/services.ts @@ -1,6 +1,8 @@ // Start the real Bridge, notary and emitted CCDP; no OAuth mocks. +// LIBID_STACK_LOGS names a file that takes every child's output, containers included. import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' +import { openSync } from 'node:fs' import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath } from 'node:url' @@ -10,13 +12,16 @@ const root = fileURLToPath(new URL('.', import.meta.url)) // Distinct Compose ownership for each checkout. No shared container names. const project = `libid-dev-${createHash('sha256').update(root).digest('hex').slice(0, 12)}` const composeArgs = ['compose', '-p', project, '-f', join(root, 'compose.yaml')] +const logs = process.env.LIBID_STACK_LOGS ? openSync(process.env.LIBID_STACK_LOGS, 'w') : undefined +const stdio = + logs === undefined ? 'inherit' : (['ignore', logs, logs] as ['ignore', number, number]) // Rebuild the shared distribution; immutable assets reuse the build cache. execFileSync( 'pnpm', ['--filter', '@libid/ceremony', 'build:ccdp-artifacts', '--out-dir', join(root, '.cache/ccdp')], { cwd: root, - stdio: 'inherit', + stdio, }, ) // Refresh the bind mounts after the build atomically replaces its output directory. @@ -25,7 +30,7 @@ if (process.argv.includes('--ccdp')) { 'docker', [...composeArgs, 'up', '--wait', '--no-deps', '--no-build', '--force-recreate', 'ccdp'], { - stdio: 'inherit', + stdio, }, ) process.exit(0) @@ -46,7 +51,7 @@ function stop(code: number) { } } // Keep this child alive through completion, including after a partial startup. - const down = spawn('docker', [...composeArgs, 'down'], { stdio: 'inherit' }) + const down = spawn('docker', [...composeArgs, 'down'], { stdio }) down.on('error', () => console.error('Could not stop development containers. Check Docker.')) down.on('close', (code) => { if (code !== 0) process.exitCode = 1 @@ -55,7 +60,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, detached: true, }) compose.on('error', () => { diff --git a/ts/apps/dev/tsconfig.json b/ts/apps/dev/tsconfig.json index 3ca17d93..92a09705 100644 --- a/ts/apps/dev/tsconfig.json +++ b/ts/apps/dev/tsconfig.json @@ -6,5 +6,5 @@ "allowImportingTsExtensions": true, "types": ["node"] }, - "include": ["*.ts", "src"] + "include": ["*.ts", "src", "e2e"] } diff --git a/ts/biome.json b/ts/biome.json index 11f27ae4..bbdf09ec 100644 --- a/ts/biome.json +++ b/ts/biome.json @@ -12,8 +12,7 @@ "!**/playwright-report", "!**/coverage", "!apps/dev/.cache", - "!apps/ceremony-live/.cache", - "!apps/ceremony-live/e2e/stealth.js", + "!apps/dev/e2e/stealth.js", "!.cache" ] }, diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index de85ba7b..a1822555 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -39,7 +39,7 @@ importers: specifier: ^7.8.5 version: 7.8.5 - apps/ceremony-live: + apps/dev: dependencies: '@libid/ceremony': specifier: workspace:^ @@ -70,34 +70,6 @@ importers: specifier: 'catalog:' version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2) - apps/dev: - dependencies: - '@libid/ceremony': - specifier: workspace:^ - version: link:../../packages/ceremony - '@libid/ledger': - specifier: workspace:^ - version: link:../../packages/ledger - '@libid/popup': - specifier: workspace:^ - version: link:../../packages/popup - '@noble/hashes': - specifier: ^2.4.0 - version: 2.4.0 - devDependencies: - '@playwright/test': - specifier: 'catalog:' - version: 1.64.0 - '@types/node': - specifier: 'catalog:' - version: 26.6.5 - typescript: - specifier: 'catalog:' - version: 7.0.2 - vite: - specifier: 'catalog:' - version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2) - packages/ceremony: dependencies: '@libid/contracts': From b98899e0d1654b4730aa19e2045167534b389885 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 20:14:39 +0300 Subject: [PATCH 040/103] ci(live-e2e): run from @libid/dev and keep only the SSM Google job The jobs run `pnpm -C ts/apps/dev test:live` and tear down the dev Compose projects. The cluster and self-hosted Google jobs are removed; the SSM-reached proxy job stays, with its temporary same-repository pull_request trigger. Failure artifacts are PNG screenshots and the stack log. Only the log is scanned for the test accounts' secrets, since it is the one file that can carry them; Playwright's error-context notes no longer go up, their error text being in the job log already. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 191 ++++---------------------------- ts/apps/dev/e2e/scan-secrets.ts | 18 +-- 2 files changed, 25 insertions(+), 184 deletions(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index b22ca684..4086c2c7 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -50,12 +50,12 @@ jobs: - uses: ./.github/actions/setup-ts - name: Install Chromium - run: pnpm -C ts --filter @libid/ceremony-live exec playwright install --with-deps chromium + run: pnpm -C ts --filter @libid/dev exec playwright install --with-deps chromium # The platforms are the repository variable LIBID_LIVE_PLATFORMS, else # GitHub and X. Google is left out by default: it has challenged a saved # session arriving from a runner's address ("Verify it's you"), which no - # automation passes. Its test still runs locally. + # automation passes. The google-ssm job runs it through a proxy. # Its own timeout, so a run that hangs still reaches the steps below: # the stack's 10-minute start, two tests of 12 minutes, and 2 minutes # to stop the stack (playwright.live.config.ts), with room to spare. @@ -77,16 +77,14 @@ jobs: # The saved Google session: base64 of a Playwright storage state or # of `ceremony export google`. GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} - run: pnpm -C ts/apps/ceremony-live test:live - - # 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 + run: pnpm -C ts/apps/dev test:live + + # The artifact is public. The stack's log, which the job log does not + # carry (playwright.live.config.ts), goes up only when the scan finds none + # of the test accounts' secrets in it. Screenshots go up unscanned: the + # pages driven never display the password, the TOTP secret or a session + # cookie. A timed-out test step counts too; a cancelled run uploads nothing. + - name: Check the stack log for secrets id: scan if: ${{ !cancelled() && (steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} env: @@ -99,10 +97,10 @@ jobs: # 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-ceremony-live | + docker compose ls -q --all --filter name=libid-dev | 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/ceremony-live/e2e/scan-secrets.ts ts/apps/ceremony-live/test-results "$RUNNER_TEMP/stack-scanned.log" + node ts/apps/dev/e2e/scan-secrets.ts "$RUNNER_TEMP/stack-scanned.log" - name: Keep the screenshots and container logs if: ${{ !cancelled() && steps.scan.outcome == 'success' }} @@ -110,91 +108,7 @@ jobs: with: name: live-e2e path: | - ts/apps/ceremony-live/test-results/**/*.png - ts/apps/ceremony-live/test-results/**/*.md - ${{ runner.temp }}/stack-scanned.log - retention-days: 14 - if-no-files-found: ignore - - # Google challenges a saved session arriving from a runner's address, and not - # from grndd-testnet's NAT address. This job routes the test browser through - # the Squid proxy in that cluster: an OIDC role that only this workflow on - # main assumes, allowed to port-forward to the proxy in namespace libid-e2e - # (grndd-systems/deployment, terraform/envs/grndd-testnet/libid-live-e2e.tf). - google: - name: Google ceremony through grndd-testnet (live) - # Runs while the repository variable LIBID_GOOGLE_RUNNER is `cluster`, and - # only on main: the role trusts nothing else. - if: >- - vars.LIBID_GOOGLE_RUNNER == 'cluster' && - github.event_name != 'pull_request' && - github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: read - id-token: write - env: - LIBID_REQUIRE_LIVE: "1" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: ./.github/actions/setup-ts - - - name: Install Chromium - run: pnpm -C ts --filter @libid/ceremony-live exec playwright install --with-deps chromium - - - uses: aws-actions/configure-aws-credentials@a03048d87541d1d9fcf2ecf528a4a65ba9bd7838 # v5.0.0 - with: - role-to-assume: arn:aws:iam::849996548040:role/grndd-testnet-libid-live-e2e - aws-region: eu-central-1 - - # The port-forward runs for the rest of the job; the step fails unless - # the proxy answers, and prints the address Google will see. - - name: Reach the Google proxy in grndd-testnet - run: | - aws eks update-kubeconfig --name grndd-testnet --region eu-central-1 - kubectl -n libid-e2e port-forward svc/google-proxy 3128:3128 >"$RUNNER_TEMP/port-forward.log" 2>&1 & - for _ in $(seq 1 30); do - ip=$(curl -fs --max-time 5 -x http://127.0.0.1:3128 https://api.ipify.org) && break - sleep 2 - done - echo "Google sees ${ip:?the proxy did not answer; see $RUNNER_TEMP/port-forward.log}" - - - name: Prove a Google identity end to end - id: test - timeout-minutes: 20 - env: - LIBID_LIVE_PLATFORMS: google - LIBID_TEST_PROXY: http://127.0.0.1:3128 - LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log - GOOGLE_TEST_ALICE_EMAIL: ${{ secrets.GOOGLE_TEST_ALICE_EMAIL }} - GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} - run: pnpm -C ts/apps/ceremony-live test:live - - # As in the ceremony job: nothing goes up unless the scan finds no secret. - - name: Check the failure output for secrets - id: scan - if: ${{ !cancelled() && (steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} - env: - LIBID_REQUIRE_LIVE: "" - GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} - run: | - docker compose ls -q --all --filter name=libid-ceremony-live | - 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/ceremony-live/e2e/scan-secrets.ts ts/apps/ceremony-live/test-results "$RUNNER_TEMP/stack-scanned.log" - - - name: Keep the screenshots and container logs - if: ${{ !cancelled() && steps.scan.outcome == 'success' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: live-e2e-google - path: | - ts/apps/ceremony-live/test-results/**/*.png - ts/apps/ceremony-live/test-results/**/*.md + ts/apps/dev/test-results/**/*.png ${{ runner.temp }}/stack-scanned.log retention-days: 14 if-no-files-found: ignore @@ -225,7 +139,7 @@ jobs: - uses: ./.github/actions/setup-ts - name: Install Chromium - run: pnpm -C ts --filter @libid/ceremony-live exec playwright install --with-deps chromium + run: pnpm -C ts --filter @libid/dev exec playwright install --with-deps chromium - uses: aws-actions/configure-aws-credentials@a03048d87541d1d9fcf2ecf528a4a65ba9bd7838 # v5.0.0 with: @@ -260,20 +174,20 @@ jobs: LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log GOOGLE_TEST_ALICE_EMAIL: ${{ secrets.GOOGLE_TEST_ALICE_EMAIL }} GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} - run: pnpm -C ts/apps/ceremony-live test:live + run: pnpm -C ts/apps/dev test:live - # As in the ceremony job: nothing goes up unless the scan finds no secret. - - name: Check the failure output for secrets + # As in the ceremony job: the log goes up only when the scan finds no secret. + - name: Check the stack log for secrets id: scan if: ${{ !cancelled() && (steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} env: LIBID_REQUIRE_LIVE: "" GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} run: | - docker compose ls -q --all --filter name=libid-ceremony-live | + docker compose ls -q --all --filter name=libid-dev | 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/ceremony-live/e2e/scan-secrets.ts ts/apps/ceremony-live/test-results "$RUNNER_TEMP/stack-scanned.log" + node ts/apps/dev/e2e/scan-secrets.ts "$RUNNER_TEMP/stack-scanned.log" - name: Keep the screenshots and container logs if: ${{ !cancelled() && steps.scan.outcome == 'success' }} @@ -281,72 +195,7 @@ jobs: with: name: live-e2e-google-ssm path: | - ts/apps/ceremony-live/test-results/**/*.png - ts/apps/ceremony-live/test-results/**/*.md - ${{ runner.temp }}/stack-scanned.log - retention-days: 14 - if-no-files-found: ignore - - # The Google ceremony on a self-hosted runner (label libid-google), for a - # runner on a machine whose address Google accepts. Opt-in: it runs only - # while the repository variable LIBID_GOOGLE_RUNNER is `self-hosted`, since a - # job for a missing runner would sit queued. It never runs for a pull - # request: only a manual dispatch, which needs write access, or the - # schedule, which runs main. - google-self-hosted: - name: Google ceremony on a self-hosted runner (live) - if: >- - vars.LIBID_GOOGLE_RUNNER == 'self-hosted' && - (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule') - runs-on: [self-hosted, libid-google] - timeout-minutes: 30 - permissions: - contents: read - env: - LIBID_REQUIRE_LIVE: "1" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: ./.github/actions/setup-ts - - - name: Install Chromium - run: pnpm -C ts --filter @libid/ceremony-live exec playwright install --with-deps chromium - - - name: Show the address Google will see - run: echo "Google sees $(curl -fs --max-time 10 https://api.ipify.org)" - - - name: Prove a Google identity end to end - id: test - timeout-minutes: 20 - env: - LIBID_LIVE_PLATFORMS: google - LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log - GOOGLE_TEST_ALICE_EMAIL: ${{ secrets.GOOGLE_TEST_ALICE_EMAIL }} - GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} - run: pnpm -C ts/apps/ceremony-live test:live - - - name: Check the failure output for secrets - id: scan - if: ${{ !cancelled() && (steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} - env: - LIBID_REQUIRE_LIVE: "" - GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} - run: | - docker compose ls -q --all --filter name=libid-ceremony-live | - 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/ceremony-live/e2e/scan-secrets.ts ts/apps/ceremony-live/test-results "$RUNNER_TEMP/stack-scanned.log" - - - name: Keep the screenshots and container logs - if: ${{ !cancelled() && steps.scan.outcome == 'success' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: live-e2e-google-self-hosted - path: | - ts/apps/ceremony-live/test-results/**/*.png - ts/apps/ceremony-live/test-results/**/*.md + ts/apps/dev/test-results/**/*.png ${{ runner.temp }}/stack-scanned.log retention-days: 14 if-no-files-found: ignore diff --git a/ts/apps/dev/e2e/scan-secrets.ts b/ts/apps/dev/e2e/scan-secrets.ts index bbe8c495..c1dde5fc 100644 --- a/ts/apps/dev/e2e/scan-secrets.ts +++ b/ts/apps/dev/e2e/scan-secrets.ts @@ -1,8 +1,7 @@ -// 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' +// Refuse to publish a failed run's stack log when it holds a test secret: the +// GitHub password or TOTP secret, or a value of a saved session. Paths are the +// arguments; a missing file is skipped. Nothing secret is printed. +import { existsSync, readFileSync } from 'node:fs' import { googleSession, xSession } from './session.ts' const sessions = [xSession(), googleSession()] @@ -12,16 +11,9 @@ const secrets = [ ...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(existsSync) .filter((file) => { const bytes = readFileSync(file) return secrets.some((secret) => bytes.includes(secret)) From c357daf1f7e17b3ad812dc75ddba581f67a8f605 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 20:14:39 +0300 Subject: [PATCH 041/103] fix(dev): skip GitHub's 2FA checkup in the live GitHub driver GitHub served its "Verify your two-factor authentication (2FA) settings" checkup at the /login/oauth/authorize URL, with no authorize button, so the driver waited out its 180 s budget. It now follows the page's "skip 2FA verification" link when the checkup shows, on any path; GitHub shows it again the next day. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/github.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/ts/apps/dev/e2e/github.ts b/ts/apps/dev/e2e/github.ts index 4e3832e5..6b32740c 100644 --- a/ts/apps/dev/e2e/github.ts +++ b/ts/apps/dev/e2e/github.ts @@ -35,6 +35,8 @@ interface Progress { totpStep?: number /** When the authorize button was last clicked; it is not clicked again within 5 s. */ authorizedAt: number + /** When the 2FA checkup was last skipped; it is not skipped again within 5 s. */ + skippedCheckupAt: number rateLimited: number errored: number } @@ -105,6 +107,15 @@ async function onAuthorize(popup: Page, progress: Progress, text: string) { ) } +async function onTwoFactorCheckup(popup: Page, progress: Progress) { + if (Date.now() - progress.skippedCheckupAt < 5_000) return + progress.skippedCheckupAt = Date.now() + await popup + .getByText('skip 2FA verification') + .click({ timeout: 5_000 }) + .catch(() => {}) +} + /** Act on one GitHub page. */ async function onGitHubPage( popup: Page, @@ -121,6 +132,9 @@ async function onGitHubPage( text.includes('something went wrong') ) return onErrorPage(popup, progress, pause) + // GitHub's periodic 2FA checkup, served in place of the page asked for. + if (text.includes('verify your two-factor authentication')) + return onTwoFactorCheckup(popup, progress) if (path === '/login' || path === '/session') return onLogin(popup, account, progress, text, pause) if (path === '/sessions/two-factor/app') return onTotp(popup, account, progress) @@ -137,11 +151,17 @@ async function onGitHubPage( * 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 + * code, skip the 2FA checkup, 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, authorizedAt: 0, rateLimited: 0, errored: 0 } + const progress: Progress = { + filled: false, + authorizedAt: 0, + skippedCheckupAt: 0, + rateLimited: 0, + errored: 0, + } await drivePopup(popup, { name: 'GitHub', onHost: (url) => url.hostname === 'github.com', From b7e81a7f4807b098a1621380200ea080198f709b Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 20:14:39 +0300 Subject: [PATCH 042/103] build(ts): keep oxlint off the dev live tests' stealth script stealth.js is a browser-fingerprint script kept byte for byte as published; biome already skips it, and oxlint failed the TypeScript lint on its empty catch blocks. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/.oxlintrc.json | 1 + 1 file changed, 1 insertion(+) diff --git a/ts/.oxlintrc.json b/ts/.oxlintrc.json index 3ca4ad80..1eaed693 100644 --- a/ts/.oxlintrc.json +++ b/ts/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "ignorePatterns": ["apps/dev/e2e/stealth.js"], "plugins": ["typescript"], "categories": { "correctness": "off" From b664d856037021f256d0ec7865644c9777bd1d54 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 20:15:04 +0300 Subject: [PATCH 043/103] docs(dev): how to run the live tests Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ts/apps/dev/README.md b/ts/apps/dev/README.md index e901da82..241086d1 100644 --- a/ts/apps/dev/README.md +++ b/ts/apps/dev/README.md @@ -96,6 +96,22 @@ the dev suite does not exhaustively test timing arithmetic, sorting or delayed delivery, or retest native disclosure controls. Real OAuth and proving checks are documented in the [ceremony qualification guide](../../packages/ceremony/docs/qualification.md). +## Live tests + +The live suite proves real GitHub, X and Google accounts from this app's UI, +with the services above. It starts `pnpm dev`, or reuses one already running. + +```sh +LIBID_TEST_ENV_FILE=path/to/.env.test pnpm --filter @libid/dev test:live +``` + +The env file holds the test accounts' secrets (`GH_TEST_ALICE_*`, +`X_TEST_ALICE_*`, `GOOGLE_TEST_ALICE_*`; see [e2e/](e2e/)). A test without its +secrets is skipped; `LIBID_REQUIRE_LIVE=1` fails it instead. +`LIBID_LIVE_PLATFORMS=github,x` picks platforms, `LIBID_TEST_PROXY` routes the +browser through a proxy, and `LIBID_STACK_LOGS=` sends the services' +output to a file. + Run history shows core operation durations from their occurrence timestamps, plus total ceremony time. The status uses the package’s stage projection. Timings freeze at the terminal outcome and clear on page reload; From 043ac98e42453c3c6961db85b4f53adf85e74b64 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 20:40:44 +0300 Subject: [PATCH 044/103] refactor(dev): one module for the app and Bridge origins src/app.ts, services.ts, vite.config.ts and the live tests each spelled the app origin or the Bridge URL. local.ts now owns both; compose.yaml and bridge-config.toml still repeat the ports they publish and admit. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/popup.ts | 3 ++- ts/apps/dev/local.ts | 6 ++++++ ts/apps/dev/playwright.live.config.ts | 4 +--- ts/apps/dev/services.ts | 15 ++++++++++----- ts/apps/dev/src/app.ts | 3 ++- ts/apps/dev/vite.config.ts | 3 ++- 6 files changed, 23 insertions(+), 11 deletions(-) create mode 100644 ts/apps/dev/local.ts diff --git a/ts/apps/dev/e2e/popup.ts b/ts/apps/dev/e2e/popup.ts index bdd98383..265f9f63 100644 --- a/ts/apps/dev/e2e/popup.ts +++ b/ts/apps/dev/e2e/popup.ts @@ -1,10 +1,11 @@ import { setTimeout as sleep } from 'node:timers/promises' import type { Page, Request } from '@playwright/test' +import { BRIDGE } from '../local.ts' const BUDGET_MS = 180_000 /** Where every platform returns the popup: the local Bridge's OAuth callback. */ -const CALLBACK = new URL('http://localhost:4682/auth/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 diff --git a/ts/apps/dev/local.ts b/ts/apps/dev/local.ts new file mode 100644 index 00000000..5fb0c8e4 --- /dev/null +++ b/ts/apps/dev/local.ts @@ -0,0 +1,6 @@ +// The origins the app, services.ts and the live tests share. compose.yaml and +// bridge-config.toml repeat the ports they need. + +/** The app, served by Vite; bridge-config.toml admits this origin. */ +export const APP_ORIGIN = 'http://localhost:4691' +export const BRIDGE = 'http://localhost:4682' diff --git a/ts/apps/dev/playwright.live.config.ts b/ts/apps/dev/playwright.live.config.ts index f9197bb8..05d13263 100644 --- a/ts/apps/dev/playwright.live.config.ts +++ b/ts/apps/dev/playwright.live.config.ts @@ -2,6 +2,7 @@ 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. @@ -12,9 +13,6 @@ if (envFile) { process.env[name] ??= value } -/** dev's application and services (services.ts, vite.config.ts). */ -const APP_ORIGIN = 'http://localhost:4691' - /** Live: real platforms, real notary, real Bridge; no chain. */ export default defineConfig({ testDir: 'e2e', diff --git a/ts/apps/dev/services.ts b/ts/apps/dev/services.ts index b312b70e..67d0dfa4 100644 --- a/ts/apps/dev/services.ts +++ b/ts/apps/dev/services.ts @@ -8,6 +8,7 @@ import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import { createServer as createViteServer, type ViteDevServer } from 'vite' +import { APP_ORIGIN, BRIDGE } from './local.ts' const root = fileURLToPath(new URL('.', import.meta.url)) // Distinct Compose ownership for each checkout. No shared container names. const project = `libid-dev-${createHash('sha256').update(root).digest('hex').slice(0, 12)}` @@ -75,11 +76,15 @@ if (process.argv.includes('--app')) { console.info('Waiting for Bridge readiness before starting the frontend…') while (!stopping) { try { - const response = await fetch('http://127.0.0.1:4682/api/v1/ceremony/config', { - headers: { Origin: 'http://localhost:4691' }, - redirect: 'error', - signal: AbortSignal.timeout(1000), - }) + // Docker publishes on 127.0.0.1 only. + const response = await fetch( + new URL('/api/v1/ceremony/config', BRIDGE.replace('localhost', '127.0.0.1')), + { + headers: { Origin: APP_ORIGIN }, + redirect: 'error', + signal: AbortSignal.timeout(1000), + }, + ) await response.body?.cancel() if (response.status === 200) break } catch { diff --git a/ts/apps/dev/src/app.ts b/ts/apps/dev/src/app.ts index f0ebd3a4..d18e6077 100644 --- a/ts/apps/dev/src/app.ts +++ b/ts/apps/dev/src/app.ts @@ -10,13 +10,14 @@ import { import type { LedgerId } from '@libid/ledger' import { PopupWindow } from '@libid/popup' import { sha256 } from '@noble/hashes/sha2.js' +import { BRIDGE } from '../local.ts' declare global { interface Window { results: Map } } -const oauthBridge = 'http://localhost:4682' +const oauthBridge = BRIDGE /** A synthetic ledger routed to the local notary. */ const ledger: LedgerId = { notaryAddress: () => 'http://localhost:4687', diff --git a/ts/apps/dev/vite.config.ts b/ts/apps/dev/vite.config.ts index fb977889..d42bc267 100644 --- a/ts/apps/dev/vite.config.ts +++ b/ts/apps/dev/vite.config.ts @@ -1,6 +1,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' +import { APP_ORIGIN } from './local.ts' const root = fileURLToPath(new URL('.', import.meta.url)) const directory = join(root, '.cache/dev') @@ -9,7 +10,7 @@ export default defineConfig({ cacheDir: join(directory, 'vite'), server: { host: 'localhost', - port: 4691, + port: Number(new URL(APP_ORIGIN).port), strictPort: true, }, build: { outDir: join(directory, 'app'), emptyOutDir: true }, From 35b50f1a8b2818c7424f9ef806cb2dc20fecf380 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 20:40:58 +0300 Subject: [PATCH 045/103] fix(dev): refuse to start services beside another stack With a port that compose.yaml publishes already taken, the Bridge readiness check could pass against a foreign stack, and the live tests would run against it. services.ts now reads the published ports from `docker compose config` and exits with the taken ports named before it builds or starts anything. `pnpm dev:ccdp`, which recreates CCDP in a running stack, skips the check. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/services.ts | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/ts/apps/dev/services.ts b/ts/apps/dev/services.ts index 67d0dfa4..3b5e44d8 100644 --- a/ts/apps/dev/services.ts +++ b/ts/apps/dev/services.ts @@ -3,12 +3,13 @@ import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { openSync } 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 { createServer as createViteServer, type ViteDevServer } from 'vite' - import { APP_ORIGIN, BRIDGE } from './local.ts' + const root = fileURLToPath(new URL('.', import.meta.url)) // Distinct Compose ownership for each checkout. No shared container names. const project = `libid-dev-${createHash('sha256').update(root).digest('hex').slice(0, 12)}` @@ -16,6 +17,39 @@ const composeArgs = ['compose', '-p', project, '-f', join(root, 'compose.yaml')] const logs = process.env.LIBID_STACK_LOGS ? openSync(process.env.LIBID_STACK_LOGS, 'w') : undefined const stdio = logs === undefined ? 'inherit' : (['ignore', logs, logs] as ['ignore', number, number]) +/** Whether something on this host accepts connections on `port`. */ +const listening = (port: number) => + 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 beside another stack on compose.yaml's ports: readiness could pass against it. +if (!process.argv.includes('--ccdp')) { + let ports: number[] + try { + const config = JSON.parse( + execFileSync('docker', [...composeArgs, 'config', '--format', 'json'], { encoding: 'utf8' }), + ) as { services: Record } + ports = Object.values(config.services).flatMap((service) => + (service.ports ?? []).map((port) => Number(port.published)), + ) + } catch { + console.error('Could not run Docker Compose. Install Docker with Compose and start its engine.') + process.exit(1) + } + const open = await Promise.all(ports.map(listening)) + const taken = ports.filter((_, index) => open[index]) + if (taken.length) { + console.error( + `Already in use on 127.0.0.1: port ${taken.join(', ')}. Stop what holds it (see docker ps) and retry.`, + ) + process.exit(1) + } +} // Rebuild the shared distribution; immutable assets reuse the build cache. execFileSync( 'pnpm', From 0be28955d3fea298f8cc0c4a82cfd718ca409a34 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 20:40:58 +0300 Subject: [PATCH 046/103] fix(dev): bound and clarify the live platform drivers - The drivers read a page's visible text, whitespace collapsed (innerText), as libid-server-rs' body_text does, not textContent with hidden script and markup text. - GitHub's login and TOTP fills and clicks, and its reloads and navigations, have timeouts; a failed step is retried on the next poll, so a stuck login ends on the driver's budget naming the page. GitHub's "incorrect username or password" fails at once. - A saved session that is not JSON fails with a fixed message: the parser's own would quote part of the secret. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/github.ts | 31 ++++++++++++++++++++++++------- ts/apps/dev/e2e/popup.ts | 17 +++++++---------- ts/apps/dev/e2e/session.ts | 11 +++++++++-- 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/ts/apps/dev/e2e/github.ts b/ts/apps/dev/e2e/github.ts index 6b32740c..561ca799 100644 --- a/ts/apps/dev/e2e/github.ts +++ b/ts/apps/dev/e2e/github.ts @@ -17,6 +17,9 @@ export function githubAccount(): GitHubAccount | undefined { return username && password && totpSecret ? { username, password, totpSecret } : undefined } +/** The longest a fill or click may take; a navigation gets twice that. */ +const STEP_TIMEOUT = 5_000 + function totp(secret: string): string { const normalized = secret.replace(/\s/g, '').toUpperCase() return new OTPAuth.TOTP({ @@ -57,7 +60,8 @@ async function backOff( async function onErrorPage(popup: Page, progress: Progress, pause: Pause) { 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(() => {}) + if (progress.start) + await popup.goto(progress.start, { timeout: STEP_TIMEOUT * 2 }).catch(() => {}) progress.filled = false progress.totpStep = undefined } @@ -71,21 +75,32 @@ async function onLogin( ) { if (text.includes('too many requests') || text.includes('rate limit')) { await backOff(progress, 'rateLimited', 30_000, 'GitHub rate-limited the login', pause) - await popup.reload().catch(() => {}) + await popup.reload({ timeout: STEP_TIMEOUT * 2 }).catch(() => {}) progress.filled = false return } + if (text.includes('incorrect username or password')) + throw new Error('GitHub refused the username or password: check GH_TEST_ALICE_*') 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]') + // A step that fails is tried again on the next poll, inside drivePopup's budget. + try { + await popup.fill('#login_field', account.username, { timeout: STEP_TIMEOUT }) + await popup.fill('#password', account.password, { timeout: STEP_TIMEOUT }) + await popup.click('input[type=submit][name=commit]', { timeout: STEP_TIMEOUT }) + } catch { + return + } 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)) + try { + await popup.fill('#app_totp', totp(account.totpSecret), { timeout: STEP_TIMEOUT }) + } catch { + return + } // GitHub submits the code on its own once six digits are in. await popup .locator('button[type=submit]') @@ -139,7 +154,9 @@ async function onGitHubPage( 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').catch(() => {}) + await popup + .goto('https://github.com/sessions/two-factor/app', { timeout: STEP_TIMEOUT * 2 }) + .catch(() => {}) return } if (path === '/login/oauth/authorize') return onAuthorize(popup, progress, text) diff --git a/ts/apps/dev/e2e/popup.ts b/ts/apps/dev/e2e/popup.ts index 265f9f63..fd729850 100644 --- a/ts/apps/dev/e2e/popup.ts +++ b/ts/apps/dev/e2e/popup.ts @@ -16,8 +16,8 @@ export interface Driver { /** 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, with - * typographic apostrophes as plain ones. + * Act on one of the platform's pages; `text` is its visible text, whitespace + * collapsed, lowercased, with typographic apostrophes as plain ones. */ step: (url: URL, text: string, pause: Pause) => Promise } @@ -57,14 +57,11 @@ export async function drivePopup(popup: Page, driver: Driver) { 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('’', "'") + const visible = await popup + .locator('body') + .innerText({ timeout: 2_000 }) + .catch(() => '') + const text = visible.split(/\s+/).join(' ').trim().toLowerCase().replaceAll('’', "'") await driver.step(url, text, pause) } await sleep(250) diff --git a/ts/apps/dev/e2e/session.ts b/ts/apps/dev/e2e/session.ts index be5787c3..4e194e5a 100644 --- a/ts/apps/dev/e2e/session.ts +++ b/ts/apps/dev/e2e/session.ts @@ -38,8 +38,15 @@ export function savedSession(prefix: string, required: string[]): Stored[] | und liveSecret(`${prefix}_COOKIES`) return undefined } - const parsed = JSON.parse(json) as Stored[] | { cookies: Stored[] } - const cookies = Array.isArray(parsed) ? parsed : parsed.cookies + let cookies: Stored[] | undefined + try { + const parsed = JSON.parse(json) as Stored[] | { cookies?: Stored[] } + cookies = Array.isArray(parsed) ? parsed : parsed.cookies + } catch { + // The parser's message would quote the value. + } + if (!Array.isArray(cookies)) + throw new Error(`The saved ${prefix} session is not a JSON cookie list or { cookies }`) if (!cookies.some((cookie) => required.includes(cookie.name))) throw new Error(`The saved ${prefix} session has none of ${required.join(', ')}`) return cookies From 5392ac32411e0b16922c0cd37a3b9c18942d73fa Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 20:40:58 +0300 Subject: [PATCH 047/103] test(dev): check the live selection, ceremony version and expiry - LIBID_LIVE_PLATFORMS entries are trimmed and must be @libid/ceremony platform ids; an unknown or miscased name is an error, and an empty selection fails under LIBID_REQUIRE_LIVE. - The spec again checks that the proof is unexpired (expiresAt after now) and that platformCeremonyVersion is a positive integer. Google's user id stays checked as non-empty and not 0x-hashed: @libid/contracts' id rules mark it non-decimal, unlike GitHub's and X's. - A relative LIBID_TEST_ENV_FILE resolves against INIT_CWD, where pnpm was invoked, else the working directory; LIBID_TEST_ENV_DIR follows it. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/ceremony.live.spec.ts | 12 ++++++++++-- ts/apps/dev/e2e/live.ts | 28 +++++++++++++++++++++------ ts/apps/dev/playwright.live.config.ts | 11 +++++++---- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/ts/apps/dev/e2e/ceremony.live.spec.ts b/ts/apps/dev/e2e/ceremony.live.spec.ts index 9c6c24b9..b45b629a 100644 --- a/ts/apps/dev/e2e/ceremony.live.spec.ts +++ b/ts/apps/dev/e2e/ceremony.live.spec.ts @@ -23,12 +23,15 @@ interface Outcome { idNodeBytes?: number handleNodeBytes?: number proofPresent?: boolean + ceremonyVersion?: string + expiresAt?: number } /** * Start the platform's ceremony from its anchor, let `authorize` drive the * popup through the platform, then check the run's result: accepted, the - * proved account with its id and handle nodes, and a non-empty proof. + * proved account with its id and handle nodes, the ceremony version, and a + * non-empty, unexpired proof. */ async function proveThroughTheUi( page: Page, @@ -64,6 +67,8 @@ async function proveThroughTheUi( proofPresent: Object.values(oauthProof.proof).some( (value) => value instanceof Uint8Array && value.length > 0, ), + ceremonyVersion: String(oauthProof.platformCeremonyVersion), + expiresAt: oauthProof.expiresAt, } }, id!) // A failed run's row says why. @@ -72,7 +77,8 @@ async function proveThroughTheUi( .textContent() expect(outcome.status, message ?? undefined).toBe('accepted') expect(outcome.platformId).toBe(platformOf[name]) - // The account id as the platform sent it: GitHub's and X's are decimal. + // The account id as the platform sent it: GitHub's and X's are decimal, and + // @libid/contracts' id rules admit any string bytes for Google's `sub`. if (name === 'Google') expect(outcome.userId).not.toMatch(/^0x/) else expect(outcome.userId).toMatch(/^\d+$/) expect(outcome.userId).toBeTruthy() @@ -80,6 +86,8 @@ async function proveThroughTheUi( expect(outcome.idNodeBytes).toBe(32) expect(outcome.handleNodeBytes).toBe(32) expect(outcome.proofPresent).toBe(true) + expect(outcome.ceremonyVersion).toMatch(/^[1-9]\d*$/) + expect(outcome.expiresAt).toBeGreaterThan(Date.now() / 1000) } test('a GitHub identity is proved in the browser', async ({ page, context }) => { diff --git a/ts/apps/dev/e2e/live.ts b/ts/apps/dev/e2e/live.ts index f2dae59e..32ce63b4 100644 --- a/ts/apps/dev/e2e/live.ts +++ b/ts/apps/dev/e2e/live.ts @@ -1,14 +1,30 @@ -import type { PlatformId } from '@libid/ceremony' +import { type PlatformId, supportedPlatforms } from '@libid/ceremony' /** - * Whether this run proves `platform`: LIBID_LIVE_PLATFORMS, comma-separated, - * or all three. A platform left out is skipped on purpose, and reported as skipped. + * The platforms this run proves: LIBID_LIVE_PLATFORMS, comma-separated, or + * every supported one. An unknown name is an error, and so is an empty + * selection under LIBID_REQUIRE_LIVE. */ -export function isLive(platform: PlatformId): boolean { - const listed = process.env.LIBID_LIVE_PLATFORMS?.split(',').map((name) => name.trim()) - return (listed ?? ['github', 'x', 'google']).includes(platform) +function livePlatforms(): readonly PlatformId[] { + const listed = process.env.LIBID_LIVE_PLATFORMS + if (listed === undefined) return supportedPlatforms + const names = listed + .split(',') + .map((name) => name.trim()) + .filter(Boolean) + const unknown = names.filter((name) => !supportedPlatforms.includes(name as PlatformId)) + if (unknown.length) + throw new Error( + `LIBID_LIVE_PLATFORMS names ${unknown.join(', ')}; use ${supportedPlatforms.join(', ')}`, + ) + if (!names.length && process.env.LIBID_REQUIRE_LIVE) + throw new Error('LIBID_REQUIRE_LIVE is set but LIBID_LIVE_PLATFORMS selects no platform') + return names as PlatformId[] } +/** Whether this run proves `platform`; one left out is skipped, and reported as skipped. */ +export const isLive = (platform: PlatformId) => livePlatforms().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 diff --git a/ts/apps/dev/playwright.live.config.ts b/ts/apps/dev/playwright.live.config.ts index 05d13263..3282c0e3 100644 --- a/ts/apps/dev/playwright.live.config.ts +++ b/ts/apps/dev/playwright.live.config.ts @@ -1,12 +1,15 @@ import { readFileSync } from 'node:fs' -import { dirname } from 'node:path' +import { dirname, resolve } 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 +// Test secrets from a dotenv file when LIBID_TEST_ENV_FILE names one, relative +// to where pnpm was invoked; values already in the environment win. Nothing is +// printed. +const envFile = + process.env.LIBID_TEST_ENV_FILE && + resolve(process.env.INIT_CWD ?? process.cwd(), process.env.LIBID_TEST_ENV_FILE) if (envFile) { process.env.LIBID_TEST_ENV_DIR ??= dirname(envFile) for (const [name, value] of Object.entries(parseEnv(readFileSync(envFile, 'utf8')))) From 9132043e1dd3c10c07f4e148e4d6396776aed131 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 20:40:58 +0300 Subject: [PATCH 048/103] ci(live-e2e): share the failure-output steps; fit the Google step timeout - Teardown, the stack-log secret scan and the upload move into the composite action .github/actions/live-e2e-output, which both jobs call with their artifact name and the secrets to look for. - The Google test step's timeout is 30 minutes, above its worst case: the 10-minute stack start, one 12-minute test and 2 minutes to stop. The job's is 45. - The SSM step no longer installs session-manager-plugin from AWS's mutable "latest" URL: the ubuntu-24.04 runner image ships it (runner images README: AWS CLI Session Manager Plugin 1.2.896.0), and the step fails with a named error if it is missing. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/actions/live-e2e-output/action.yml | 40 +++++++++++++ .github/workflows/live-e2e.yml | 66 ++++++---------------- 2 files changed, 58 insertions(+), 48 deletions(-) create mode 100644 .github/actions/live-e2e-output/action.yml diff --git a/.github/actions/live-e2e-output/action.yml b/.github/actions/live-e2e-output/action.yml new file mode 100644 index 00000000..dbad81ff --- /dev/null +++ b/.github/actions/live-e2e-output/action.yml @@ -0,0 +1,40 @@ +name: Keep the live end-to-end failure output +description: >- + Stop any dev stack left running, scan its log for the test accounts' + secrets, then upload the PNG screenshots and the scanned log. The caller + passes the secrets to look for as env and runs this only on failure. + +# Every third-party action is pinned by commit SHA, with the tag in a +# comment, so a moved tag cannot change what executes. + +inputs: + name: + description: The artifact's name. + required: true + +runs: + using: composite + steps: + # 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. A secret + # in it fails this step, and nothing goes up. + - name: Check the stack log for secrets + shell: bash + env: + # A session that is not configured is nothing to look for. + LIBID_REQUIRE_LIVE: "" + run: | + docker compose ls -q --all --filter name=libid-dev | + 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/dev/e2e/scan-secrets.ts "$RUNNER_TEMP/stack-scanned.log" + + - name: Keep the screenshots and the stack log + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.name }} + path: | + ts/apps/dev/test-results/**/*.png + ${{ runner.temp }}/stack-scanned.log + retention-days: 14 + if-no-files-found: ignore diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 4086c2c7..5077396b 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -84,34 +84,16 @@ jobs: # of the test accounts' secrets in it. Screenshots go up unscanned: the # pages driven never display the password, the TOTP secret or a session # cookie. A timed-out test step counts too; a cancelled run uploads nothing. - - name: Check the stack log for secrets - id: scan + - name: Keep the failure output if: ${{ !cancelled() && (steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} + uses: ./.github/actions/live-e2e-output + with: + name: live-e2e env: - # 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 }} GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} - # 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-dev | - 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/dev/e2e/scan-secrets.ts "$RUNNER_TEMP/stack-scanned.log" - - - name: Keep the screenshots and container logs - if: ${{ !cancelled() && steps.scan.outcome == 'success' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: live-e2e - path: | - ts/apps/dev/test-results/**/*.png - ${{ runner.temp }}/stack-scanned.log - retention-days: 14 - if-no-files-found: ignore google-ssm: name: Google ceremony through an SSM-reached proxy (live) @@ -125,7 +107,8 @@ jobs: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) runs-on: ubuntu-latest - timeout-minutes: 30 + # The test step's budget plus setup, the proxy, the secret scan and the upload. + timeout-minutes: 45 permissions: contents: read id-token: write @@ -151,11 +134,10 @@ jobs: - name: Reach the Google proxy over SSM env: INSTANCE: ${{ vars.LIBID_GOOGLE_PROXY_INSTANCE }} + # The runner image ships session-manager-plugin. run: | - if ! command -v session-manager-plugin >/dev/null; then - curl -fsSLo "$RUNNER_TEMP/smp.deb" https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb - sudo dpkg -i "$RUNNER_TEMP/smp.deb" - fi + command -v session-manager-plugin >/dev/null || + { echo "::error::session-manager-plugin is not on this runner image"; exit 1; } aws ssm start-session --target "$INSTANCE" \ --document-name AWS-StartPortForwardingSession \ --parameters portNumber=3128,localPortNumber=3128 >"$RUNNER_TEMP/port-forward.log" 2>&1 & @@ -165,9 +147,12 @@ jobs: done echo "Google sees ${ip:?the proxy did not answer; see $RUNNER_TEMP/port-forward.log}" + # Its own timeout, so a run that hangs still reaches the step below: the + # stack's 10-minute start, one test of 12 minutes, and 2 minutes to stop + # the stack (playwright.live.config.ts), with room to spare. - name: Prove a Google identity end to end id: test - timeout-minutes: 20 + timeout-minutes: 30 env: LIBID_LIVE_PLATFORMS: google LIBID_TEST_PROXY: http://127.0.0.1:3128 @@ -176,26 +161,11 @@ jobs: GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} run: pnpm -C ts/apps/dev test:live - # As in the ceremony job: the log goes up only when the scan finds no secret. - - name: Check the stack log for secrets - id: scan + # As in the ceremony job. + - name: Keep the failure output if: ${{ !cancelled() && (steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} - env: - LIBID_REQUIRE_LIVE: "" - GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} - run: | - docker compose ls -q --all --filter name=libid-dev | - 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/dev/e2e/scan-secrets.ts "$RUNNER_TEMP/stack-scanned.log" - - - name: Keep the screenshots and container logs - if: ${{ !cancelled() && steps.scan.outcome == 'success' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: ./.github/actions/live-e2e-output with: name: live-e2e-google-ssm - path: | - ts/apps/dev/test-results/**/*.png - ${{ runner.temp }}/stack-scanned.log - retention-days: 14 - if-no-files-found: ignore + env: + GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} From 291ae1bb4f3c841f380bf1243c4645ae5cc31ab3 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 20:40:58 +0300 Subject: [PATCH 049/103] docs(dev): list the live tests' variables; name local.ts The live tests section moves after the run-history paragraphs it split and lists every variable the suite reads. Configure names local.ts for the app origin and Bridge URL. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/README.md | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/ts/apps/dev/README.md b/ts/apps/dev/README.md index 241086d1..57c4189c 100644 --- a/ts/apps/dev/README.md +++ b/ts/apps/dev/README.md @@ -37,8 +37,8 @@ Edit configuration directly where it is used: - [compose.yaml](compose.yaml): services. - [bridge-config.toml](bridge-config.toml): Bridge configuration and development OAuth credentials. -- [src/app.ts](src/app.ts): Bridge URL and the local ledger/notary fixture; Bridge configuration selects CCDP. -- [vite.config.ts](vite.config.ts): frontend port. +- [local.ts](local.ts): the app origin (frontend port) and Bridge URL. +- [src/app.ts](src/app.ts): the local ledger/notary fixture; Bridge configuration selects CCDP. The setup includes shared development OAuth credentials and a public development notary signing key; no environment file is needed. Bridge publishes the GitHub @@ -96,22 +96,6 @@ the dev suite does not exhaustively test timing arithmetic, sorting or delayed delivery, or retest native disclosure controls. Real OAuth and proving checks are documented in the [ceremony qualification guide](../../packages/ceremony/docs/qualification.md). -## Live tests - -The live suite proves real GitHub, X and Google accounts from this app's UI, -with the services above. It starts `pnpm dev`, or reuses one already running. - -```sh -LIBID_TEST_ENV_FILE=path/to/.env.test pnpm --filter @libid/dev test:live -``` - -The env file holds the test accounts' secrets (`GH_TEST_ALICE_*`, -`X_TEST_ALICE_*`, `GOOGLE_TEST_ALICE_*`; see [e2e/](e2e/)). A test without its -secrets is skipped; `LIBID_REQUIRE_LIVE=1` fails it instead. -`LIBID_LIVE_PLATFORMS=github,x` picks platforms, `LIBID_TEST_PROXY` routes the -browser through a proxy, and `LIBID_STACK_LOGS=` sends the services' -output to a file. - Run history shows core operation durations from their occurrence timestamps, plus total ceremony time. The status uses the package’s stage projection. Timings freeze at the terminal outcome and clear on page reload; @@ -121,3 +105,24 @@ When isolation fallback occurs, **Prover fallback** shows navigation through Pro readiness, using `prover-fallback` and `prover.started` occurrence timestamps. It excludes work before navigation and does not measure the difference from a run without fallback. Direct runs have no fallback row. + +## Live tests + +The live suite proves real GitHub, X and Google accounts from this app's UI, +with the services above. It starts `pnpm dev`, or reuses one already running, +and refuses to start beside another stack on the same ports. + +```sh +LIBID_TEST_ENV_FILE=path/to/.env.test pnpm --filter @libid/dev test:live +``` + +| Variable | Use | +|---|---| +| `LIBID_TEST_ENV_FILE` | A dotenv file with the variables below, relative to where you run pnpm. | +| `GH_TEST_ALICE_USERNAME`, `GH_TEST_ALICE_PASSWORD`, `GH_TEST_ALICE_TOTP_SECRET` | The GitHub test account and its authenticator secret. | +| `X_TEST_ALICE_USERNAME`, `X_TEST_ALICE_COOKIES` or `X_TEST_ALICE_COOKIES_FILE` | The X handle and a saved session (base64 JSON, or a path relative to the env file). | +| `GOOGLE_TEST_ALICE_EMAIL`, `GOOGLE_TEST_ALICE_COOKIES` or `GOOGLE_TEST_ALICE_COOKIES_FILE` | The Google account and a saved session, as for X. | +| `LIBID_LIVE_PLATFORMS` | Comma-separated platforms to prove, such as `github,x`; all three by default. | +| `LIBID_REQUIRE_LIVE` | Set: a missing secret or an empty platform list fails instead of skipping. | +| `LIBID_TEST_PROXY` | A proxy for the browser's traffic, loopback aside. | +| `LIBID_STACK_LOGS` | A file for the services' output, containers included, instead of the terminal. | From 55f4b51db996b06325c01e2dfd88ff59961a94c3 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 20:47:24 +0300 Subject: [PATCH 050/103] test(dev): read the live tests' settings through one typed table e2e/settings.ts lists every variable the live tests read, with its section and need, in the shape and under the names of libid-server-rs' ceremony-tests settings.rs, so the shared .env.test keeps working for both suites. Typed readers per section replace the scattered env reads in live.ts (removed), session.ts, the GitHub driver, the spec and the secret scan; the scan reads the GitHub secrets one by one, since its CI step is given no username. The runner process checks every selected platform once at config load: under LIBID_REQUIRE_LIVE, one error names each absent variable by platform and where to put it, before the stack starts. Keys of the env file that look like settings (*_TEST_ALICE_*, LIBID_*) but that neither suite reads are reported as likely typos; the Rust suite's own keys are listed by name, since they share those prefixes. The platform list comes from the section table's keys, typed by @libid/ceremony's PlatformId, so the config loads without the built package. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/ceremony.live.spec.ts | 33 +++-- ts/apps/dev/e2e/github.ts | 16 +-- ts/apps/dev/e2e/live.ts | 39 ------ ts/apps/dev/e2e/scan-secrets.ts | 4 +- ts/apps/dev/e2e/session.ts | 48 ++++--- ts/apps/dev/e2e/settings.ts | 183 ++++++++++++++++++++++++++ ts/apps/dev/playwright.live.config.ts | 29 ++-- 7 files changed, 245 insertions(+), 107 deletions(-) delete mode 100644 ts/apps/dev/e2e/live.ts create mode 100644 ts/apps/dev/e2e/settings.ts diff --git a/ts/apps/dev/e2e/ceremony.live.spec.ts b/ts/apps/dev/e2e/ceremony.live.spec.ts index b45b629a..754242b0 100644 --- a/ts/apps/dev/e2e/ceremony.live.spec.ts +++ b/ts/apps/dev/e2e/ceremony.live.spec.ts @@ -1,10 +1,17 @@ import type { PlatformId } from '@libid/ceremony' import { type BrowserContext, expect, type Page, test } from '@playwright/test' -import { authorizeOnGitHub, githubAccount } from './github.ts' +import { authorizeOnGitHub } from './github.ts' import { authorizeOnGoogle } from './google.ts' -import { isLive, liveSecret } from './live.ts' import { presentAsPerson } from './person.ts' import { googleSession, restoreSession, xSession } from './session.ts' +import { + githubAccount, + googleAccount, + isLive, + missing, + type Section, + xAccount, +} from './settings.ts' import { authorizeOnX, restoreXSession } from './x.ts' /** Proving in the browser, after the platform returns the popup. */ @@ -90,10 +97,14 @@ async function proveThroughTheUi( expect(outcome.expiresAt).toBeGreaterThan(Date.now() / 1000) } +/** Why `section`'s test is skipped: the variables it lacks. */ +const lacking = (...sections: Section[]) => + `${sections.flatMap((s) => missing(s)).join(', ')} not set` + test('a GitHub identity is proved in the browser', 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') + test.skip(!account, lacking('github-account')) await proveThroughTheUi(page, context, 'GitHub', account!.username, (popup) => authorizeOnGitHub(popup, account!), ) @@ -102,23 +113,21 @@ test('a GitHub identity is proved in the browser', async ({ page, context }) => test('an X identity is proved in the browser', 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 = liveSecret('X_TEST_ALICE_USERNAME') - test.skip(!username, 'X_TEST_ALICE_USERNAME is not set') + const account = xAccount() + test.skip(!session || !account, lacking('x-account', 'x-session')) await presentAsPerson(context) await restoreXSession(context, session!) - await proveThroughTheUi(page, context, 'X', username!, (popup) => authorizeOnX(popup)) + await proveThroughTheUi(page, context, 'X', account!.username, (popup) => authorizeOnX(popup)) }) test('a Google identity is proved in the browser', async ({ 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 = liveSecret('GOOGLE_TEST_ALICE_EMAIL') - test.skip(!email, 'GOOGLE_TEST_ALICE_EMAIL is not set') + const account = googleAccount() + test.skip(!session || !account, lacking('google-account', 'google-session')) await presentAsPerson(context) await restoreSession(context, session!) - await proveThroughTheUi(page, context, 'Google', email!, (popup) => - authorizeOnGoogle(popup, email!), + await proveThroughTheUi(page, context, 'Google', account!.email, (popup) => + authorizeOnGoogle(popup, account!.email), ) }) diff --git a/ts/apps/dev/e2e/github.ts b/ts/apps/dev/e2e/github.ts index 561ca799..da743dad 100644 --- a/ts/apps/dev/e2e/github.ts +++ b/ts/apps/dev/e2e/github.ts @@ -1,21 +1,7 @@ import type { Page } from '@playwright/test' import * as OTPAuth from 'otpauth' -import { liveSecret } from './live.ts' import { drivePopup, type Pause } from './popup.ts' - -export interface GitHubAccount { - username: string - password: string - totpSecret: string -} - -/** The test account, from GH_TEST_ALICE_*; undefined when any is missing (see liveSecret). */ -export function githubAccount(): GitHubAccount | 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 -} +import type { GitHubAccount } from './settings.ts' /** The longest a fill or click may take; a navigation gets twice that. */ const STEP_TIMEOUT = 5_000 diff --git a/ts/apps/dev/e2e/live.ts b/ts/apps/dev/e2e/live.ts deleted file mode 100644 index 32ce63b4..00000000 --- a/ts/apps/dev/e2e/live.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { type PlatformId, supportedPlatforms } from '@libid/ceremony' - -/** - * The platforms this run proves: LIBID_LIVE_PLATFORMS, comma-separated, or - * every supported one. An unknown name is an error, and so is an empty - * selection under LIBID_REQUIRE_LIVE. - */ -function livePlatforms(): readonly PlatformId[] { - const listed = process.env.LIBID_LIVE_PLATFORMS - if (listed === undefined) return supportedPlatforms - const names = listed - .split(',') - .map((name) => name.trim()) - .filter(Boolean) - const unknown = names.filter((name) => !supportedPlatforms.includes(name as PlatformId)) - if (unknown.length) - throw new Error( - `LIBID_LIVE_PLATFORMS names ${unknown.join(', ')}; use ${supportedPlatforms.join(', ')}`, - ) - if (!names.length && process.env.LIBID_REQUIRE_LIVE) - throw new Error('LIBID_REQUIRE_LIVE is set but LIBID_LIVE_PLATFORMS selects no platform') - return names as PlatformId[] -} - -/** Whether this run proves `platform`; one left out is skipped, and reported as skipped. */ -export const isLive = (platform: PlatformId) => livePlatforms().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/dev/e2e/scan-secrets.ts b/ts/apps/dev/e2e/scan-secrets.ts index c1dde5fc..7660e038 100644 --- a/ts/apps/dev/e2e/scan-secrets.ts +++ b/ts/apps/dev/e2e/scan-secrets.ts @@ -3,11 +3,11 @@ // arguments; a missing file is skipped. Nothing secret is printed. import { existsSync, readFileSync } from 'node:fs' import { googleSession, xSession } from './session.ts' +import { githubSecrets } from './settings.ts' const sessions = [xSession(), googleSession()] const secrets = [ - process.env.GH_TEST_ALICE_PASSWORD, - process.env.GH_TEST_ALICE_TOTP_SECRET, + ...githubSecrets(), ...sessions.flatMap((cookies) => cookies?.map((cookie) => cookie.value) ?? []), ].filter((secret): secret is string => !!secret && secret.length >= 8) diff --git a/ts/apps/dev/e2e/session.ts b/ts/apps/dev/e2e/session.ts index 4e194e5a..c47eb9f4 100644 --- a/ts/apps/dev/e2e/session.ts +++ b/ts/apps/dev/e2e/session.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' -import { isAbsolute, join } from 'node:path' import type { BrowserContext } from '@playwright/test' -import { liveSecret } from './live.ts' +import * as settings from './settings.ts' +import type { SavedSession } from './settings.ts' /** One cookie as libid-server-rs' `ceremony export` saves it; both key casings occur. */ export interface Stored { @@ -18,26 +18,20 @@ export interface Stored { } /** - * 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. + * The cookies of a saved session setting, or undefined when it is not 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 { - // Neither is set: fails under LIBID_REQUIRE_LIVE, skips otherwise. - liveSecret(`${prefix}_COOKIES`) - return undefined - } +function cookiesOf( + saved: SavedSession | undefined, + label: string, + required: string[], +): Stored[] | undefined { + if (!saved) return undefined + const json = + 'encoded' in saved + ? Buffer.from(saved.encoded.trim(), 'base64').toString('utf8') + : readFileSync(saved.path, 'utf8') let cookies: Stored[] | undefined try { const parsed = JSON.parse(json) as Stored[] | { cookies?: Stored[] } @@ -46,9 +40,9 @@ export function savedSession(prefix: string, required: string[]): Stored[] | und // The parser's message would quote the value. } if (!Array.isArray(cookies)) - throw new Error(`The saved ${prefix} session is not a JSON cookie list or { cookies }`) + throw new Error(`The saved ${label} session is not a JSON cookie list or { cookies }`) if (!cookies.some((cookie) => required.includes(cookie.name))) - throw new Error(`The saved ${prefix} session has none of ${required.join(', ')}`) + throw new Error(`The saved ${label} session has none of ${required.join(', ')}`) return cookies } @@ -58,7 +52,7 @@ export function savedSession(prefix: string, required: string[]): Stored[] | und * 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']) +export const xSession = () => cookiesOf(settings.xSession(), 'X_TEST_ALICE', ['auth_token']) /** * The saved Google session, from GOOGLE_TEST_ALICE_COOKIES or @@ -68,7 +62,11 @@ export const xSession = () => savedSession('X_TEST_ALICE', ['auth_token']) * sign-in in an automated browser, but honours a session made elsewhere. */ export const googleSession = () => - savedSession('GOOGLE_TEST_ALICE', ['SID', '__Secure-1PSID', '__Secure-3PSID']) + cookiesOf(settings.googleSession(), 'GOOGLE_TEST_ALICE', [ + 'SID', + '__Secure-1PSID', + '__Secure-3PSID', + ]) const SAME_SITE = new Map([ ['strict', 'Strict'], diff --git a/ts/apps/dev/e2e/settings.ts b/ts/apps/dev/e2e/settings.ts new file mode 100644 index 00000000..f3a41111 --- /dev/null +++ b/ts/apps/dev/e2e/settings.ts @@ -0,0 +1,183 @@ +// The settings the live tests read: every variable, its section and whether +// the section needs it, in VARIABLES; one typed reader per section. The names +// are those of libid-server-rs' ceremony-tests (src/settings.rs), which reads +// the same .env.test. An empty value is an absent one. +import { dirname, isAbsolute, join, resolve } from 'node:path' +import type { PlatformId } from '@libid/ceremony' + +/** Whether a section needs a variable; `insteadOf` stands in for that required one. */ +export type Need = 'required' | 'optional' | { insteadOf: string } + +export type Section = + | 'github-account' + | 'x-account' + | 'x-session' + | 'google-account' + | 'google-session' + | 'tooling' + +/** Every variable the live tests read: its name, its section, and its need. */ +export const VARIABLES: readonly (readonly [name: string, section: Section, need: Need])[] = [ + ['GH_TEST_ALICE_USERNAME', 'github-account', 'required'], + ['GH_TEST_ALICE_PASSWORD', 'github-account', 'required'], + ['GH_TEST_ALICE_TOTP_SECRET', 'github-account', 'required'], + ['X_TEST_ALICE_USERNAME', 'x-account', 'required'], + ['X_TEST_ALICE_COOKIES', 'x-session', 'required'], + ['X_TEST_ALICE_COOKIES_FILE', 'x-session', { insteadOf: 'X_TEST_ALICE_COOKIES' }], + ['GOOGLE_TEST_ALICE_EMAIL', 'google-account', 'required'], + ['GOOGLE_TEST_ALICE_COOKIES', 'google-session', 'required'], + ['GOOGLE_TEST_ALICE_COOKIES_FILE', 'google-session', { insteadOf: 'GOOGLE_TEST_ALICE_COOKIES' }], + ['LIBID_TEST_ENV_FILE', 'tooling', 'optional'], + ['LIBID_LIVE_PLATFORMS', 'tooling', 'optional'], + ['LIBID_REQUIRE_LIVE', 'tooling', 'optional'], + ['LIBID_TEST_PROXY', 'tooling', 'optional'], + // Read by services.ts, in the stack's own process. + ['LIBID_STACK_LOGS', 'tooling', 'optional'], +] + +/** The sections each platform's live test reads; the type keeps every platform listed. */ +export const PLATFORM_SECTIONS: Record = { + github: ['github-account'], + x: ['x-account', 'x-session'], + google: ['google-account', 'google-session'], +} + +const PLATFORMS = Object.keys(PLATFORM_SECTIONS) as PlatformId[] + +/** Variables of the shared .env.test that only the Rust suite reads. */ +const RUST_SUITE_ONLY = [ + 'LIBID_TEST_PUBLIC_ORIGIN', + 'LIBID_TEST_X_REDIRECT_URI', + 'LIBID_TEST_GOOGLE_REDIRECT_URI', + 'X_TEST_ALICE_EMAIL', + 'X_TEST_ALICE_PASSWORD', + 'GOOGLE_TEST_ALICE_PASSWORD', +] + +/** The value of a name, if it has one. */ +export type Lookup = (name: string) => string | undefined + +const environment: Lookup = (name) => process.env[name] || undefined + +/** The settings file LIBID_TEST_ENV_FILE names, relative to where pnpm was invoked. */ +export function envFile(get: Lookup = environment): string | undefined { + const file = get('LIBID_TEST_ENV_FILE') + return file && resolve(get('INIT_CWD') ?? process.cwd(), file) +} + +export interface Tooling { + /** LIBID_LIVE_PLATFORMS, trimmed and checked, or every supported platform. */ + platforms: readonly PlatformId[] + /** A missing setting fails the run instead of skipping its test. */ + requireLive: boolean + proxy?: string +} + +export function tooling(get: Lookup = environment): Tooling { + const listed = get('LIBID_LIVE_PLATFORMS') + const names = listed + ?.split(',') + .map((name) => name.trim()) + .filter(Boolean) + const unknown = names?.filter((name) => !PLATFORMS.includes(name as PlatformId)) ?? [] + if (unknown.length) + throw new Error(`LIBID_LIVE_PLATFORMS names ${unknown.join(', ')}; use ${PLATFORMS.join(', ')}`) + return { + platforms: (names as PlatformId[] | undefined) ?? PLATFORMS, + requireLive: get('LIBID_REQUIRE_LIVE') !== undefined, + proxy: get('LIBID_TEST_PROXY'), + } +} + +/** Whether this run proves `platform`; one left out is skipped, and reported as skipped. */ +export const isLive = (platform: PlatformId) => tooling().platforms.includes(platform) + +/** The required variables of `section` that `get` finds absent; `A (or B)` where B stands in. */ +export function missing(section: Section, get: Lookup = environment): string[] { + return VARIABLES.filter(([name, of, need]) => of === section && need === 'required' && !get(name)) + .map(([name]) => { + const standIn = VARIABLES.find( + ([, , need]) => typeof need === 'object' && need.insteadOf === name, + )?.[0] + if (!standIn) return name + return get(standIn) ? undefined : `${name} (or ${standIn})` + }) + .filter((name): name is string => name !== undefined) +} + +/** + * Check every selected platform's sections at once: with LIBID_REQUIRE_LIVE, + * one error names every absent variable by platform. + */ +export function checkSettings(get: Lookup = environment) { + const { platforms, requireLive } = tooling(get) + if (requireLive && !platforms.length) + throw new Error('LIBID_REQUIRE_LIVE is set but LIBID_LIVE_PLATFORMS selects no platform') + if (!requireLive) return + const absent = platforms.flatMap((platform) => { + const names = PLATFORM_SECTIONS[platform].flatMap((section) => missing(section, get)) + return names.length ? [`${platform} needs ${names.join(', ')}`] : [] + }) + if (absent.length) + throw new Error( + `The live tests are missing settings: ${absent.join('; ')}. Put them in the file LIBID_TEST_ENV_FILE names, or export them; in CI they are repository secrets passed to the job (.github/workflows/live-e2e.yml).`, + ) +} + +/** Keys of the settings file that look like settings but neither suite reads: likely typos. */ +export function unknownKeys(keys: readonly string[]): string[] { + return keys.filter( + (key) => + (/^[A-Z]+_TEST_ALICE_/.test(key) || key.startsWith('LIBID_')) && + !VARIABLES.some(([name]) => name === key) && + !RUST_SUITE_ONLY.includes(key), + ) +} + +/** The section's values, or undefined when one it requires is absent. */ +function read(section: Section, get: Lookup, value: () => T): T | undefined { + return missing(section, get).length ? undefined : value() +} + +export interface GitHubAccount { + username: string + password: string + /** The base32 key of the account's authenticator app. */ + totpSecret: string +} + +export const githubAccount = (get: Lookup = environment) => + read('github-account', get, () => ({ + username: get('GH_TEST_ALICE_USERNAME')!, + password: get('GH_TEST_ALICE_PASSWORD')!, + totpSecret: get('GH_TEST_ALICE_TOTP_SECRET')!, + })) + +/** The GitHub account's secrets that are set, for the failure-output scan. */ +export const githubSecrets = (get: Lookup = environment) => + [get('GH_TEST_ALICE_PASSWORD'), get('GH_TEST_ALICE_TOTP_SECRET')].filter( + (secret): secret is string => secret !== undefined, + ) + +export const xAccount = (get: Lookup = environment) => + read('x-account', get, () => ({ username: get('X_TEST_ALICE_USERNAME')! })) + +export const googleAccount = (get: Lookup = environment) => + read('google-account', get, () => ({ email: get('GOOGLE_TEST_ALICE_EMAIL')! })) + +/** A saved session: base64 of the JSON cookie list, or a path to that JSON. */ +export type SavedSession = { encoded: string } | { path: string } + +function savedSession(prefix: string, get: Lookup): SavedSession | undefined { + const encoded = get(`${prefix}_COOKIES`) + if (encoded) return { encoded } + const file = get(`${prefix}_COOKIES_FILE`) + if (!file) return undefined + // A relative path is the settings file's neighbour. + const directory = envFile(get) + return { path: isAbsolute(file) || !directory ? file : join(dirname(directory), file) } +} + +export const xSession = (get: Lookup = environment) => savedSession('X_TEST_ALICE', get) + +export const googleSession = (get: Lookup = environment) => savedSession('GOOGLE_TEST_ALICE', get) diff --git a/ts/apps/dev/playwright.live.config.ts b/ts/apps/dev/playwright.live.config.ts index 3282c0e3..607f560e 100644 --- a/ts/apps/dev/playwright.live.config.ts +++ b/ts/apps/dev/playwright.live.config.ts @@ -1,20 +1,23 @@ import { readFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' import { parseEnv } from 'node:util' import { defineConfig, devices } from '@playwright/test' +import { checkSettings, envFile, tooling, unknownKeys } from './e2e/settings.ts' import { APP_ORIGIN } from './local.ts' -// Test secrets from a dotenv file when LIBID_TEST_ENV_FILE names one, relative -// to where pnpm was invoked; values already in the environment win. Nothing is -// printed. -const envFile = - process.env.LIBID_TEST_ENV_FILE && - resolve(process.env.INIT_CWD ?? process.cwd(), process.env.LIBID_TEST_ENV_FILE) -if (envFile) { - process.env.LIBID_TEST_ENV_DIR ??= dirname(envFile) - for (const [name, value] of Object.entries(parseEnv(readFileSync(envFile, 'utf8')))) - process.env[name] ??= value +// Test secrets from a dotenv file when LIBID_TEST_ENV_FILE names one; values +// already in the environment win. Nothing is printed. The runner process +// checks them once; its workers load this file too. +const runner = process.env.TEST_WORKER_INDEX === undefined +const file = envFile() +if (file) { + const values = parseEnv(readFileSync(file, 'utf8')) + for (const [name, value] of Object.entries(values)) process.env[name] ??= value + const typos = unknownKeys(Object.keys(values)) + if (runner && typos.length) + console.warn(`${file}: no live test reads ${typos.join(', ')}; check the spelling.`) } +if (runner) checkSettings() +const { proxy } = tooling() /** Live: real platforms, real notary, real Bridge; no chain. */ export default defineConfig({ @@ -44,9 +47,7 @@ export default defineConfig({ launchOptions: { args: ['--disable-blink-features=AutomationControlled'] }, // LIBID_TEST_PROXY routes the browser's traffic, loopback aside, through a // proxy: the platforms then see the proxy's address instead of this host's. - ...(process.env.LIBID_TEST_PROXY && { - proxy: { server: process.env.LIBID_TEST_PROXY, bypass: 'localhost,127.0.0.1,[::1]' }, - }), + ...(proxy && { proxy: { server: proxy, bypass: 'localhost,127.0.0.1,[::1]' } }), // 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', From 8e749792c796609bf0a42bc168cacd8afa944970 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 20:47:24 +0300 Subject: [PATCH 051/103] test(dev): check the settings table against the workflow and README `pnpm --filter @libid/dev test` (node:test, no secrets, run by CI's TypeScript job) checks that every secret live-e2e.yml reads, and every setting-like env key it sets, is in the table under its own name; that each job's test step is given what its platforms require; and that the README's table lists every setting with its section and need, and nothing else. The README's live tests table takes that one-row-per- variable shape. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/README.md | 33 ++++++++---- ts/apps/dev/e2e/settings.test.ts | 90 ++++++++++++++++++++++++++++++++ ts/apps/dev/package.json | 1 + 3 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 ts/apps/dev/e2e/settings.test.ts diff --git a/ts/apps/dev/README.md b/ts/apps/dev/README.md index 57c4189c..11f97e13 100644 --- a/ts/apps/dev/README.md +++ b/ts/apps/dev/README.md @@ -116,13 +116,26 @@ and refuses to start beside another stack on the same ports. LIBID_TEST_ENV_FILE=path/to/.env.test pnpm --filter @libid/dev test:live ``` -| Variable | Use | -|---|---| -| `LIBID_TEST_ENV_FILE` | A dotenv file with the variables below, relative to where you run pnpm. | -| `GH_TEST_ALICE_USERNAME`, `GH_TEST_ALICE_PASSWORD`, `GH_TEST_ALICE_TOTP_SECRET` | The GitHub test account and its authenticator secret. | -| `X_TEST_ALICE_USERNAME`, `X_TEST_ALICE_COOKIES` or `X_TEST_ALICE_COOKIES_FILE` | The X handle and a saved session (base64 JSON, or a path relative to the env file). | -| `GOOGLE_TEST_ALICE_EMAIL`, `GOOGLE_TEST_ALICE_COOKIES` or `GOOGLE_TEST_ALICE_COOKIES_FILE` | The Google account and a saved session, as for X. | -| `LIBID_LIVE_PLATFORMS` | Comma-separated platforms to prove, such as `github,x`; all three by default. | -| `LIBID_REQUIRE_LIVE` | Set: a missing secret or an empty platform list fails instead of skipping. | -| `LIBID_TEST_PROXY` | A proxy for the browser's traffic, loopback aside. | -| `LIBID_STACK_LOGS` | A file for the services' output, containers included, instead of the terminal. | +The variables, read through [e2e/settings.ts](e2e/settings.ts), carry the +names libid-server-rs' ceremony-tests uses, so one `.env.test` serves both +suites. With `LIBID_REQUIRE_LIVE`, the run checks every selected platform's +variables at once and names each one absent; without it, a test lacking its +variables is skipped. Keys in the env file that look like settings but that +neither suite reads are reported as likely typos. + +| Variable | Section | Need | Use | +|---|---|---|---| +| `GH_TEST_ALICE_USERNAME` | github-account | required | The GitHub test account. | +| `GH_TEST_ALICE_PASSWORD` | github-account | required | Its password. | +| `GH_TEST_ALICE_TOTP_SECRET` | github-account | required | The base32 key of its authenticator app. | +| `X_TEST_ALICE_USERNAME` | x-account | required | The X test account's handle. | +| `X_TEST_ALICE_COOKIES` | x-session | required | Its saved session, base64 of the JSON cookie list. | +| `X_TEST_ALICE_COOKIES_FILE` | x-session | instead of `X_TEST_ALICE_COOKIES` | A path to that JSON, relative to the env file. | +| `GOOGLE_TEST_ALICE_EMAIL` | google-account | required | The Google test account. | +| `GOOGLE_TEST_ALICE_COOKIES` | google-session | required | Its saved session, as for X. | +| `GOOGLE_TEST_ALICE_COOKIES_FILE` | google-session | instead of `GOOGLE_TEST_ALICE_COOKIES` | A path to that JSON, as for X. | +| `LIBID_TEST_ENV_FILE` | tooling | optional | A dotenv file with the variables above, relative to where you run pnpm; exported values win. | +| `LIBID_LIVE_PLATFORMS` | tooling | optional | Comma-separated platforms to prove, such as `github,x`; all three by default. | +| `LIBID_REQUIRE_LIVE` | tooling | optional | Set: a missing variable or an empty platform list fails instead of skipping. | +| `LIBID_TEST_PROXY` | tooling | optional | A proxy for the browser's traffic, loopback aside. | +| `LIBID_STACK_LOGS` | tooling | optional | A file for the services' output, containers included, instead of the terminal. | diff --git a/ts/apps/dev/e2e/settings.test.ts b/ts/apps/dev/e2e/settings.test.ts new file mode 100644 index 00000000..fd6560f7 --- /dev/null +++ b/ts/apps/dev/e2e/settings.test.ts @@ -0,0 +1,90 @@ +// The settings table against the places that repeat it: the live workflow and +// the README. Read as text; no secrets, no network. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import type { PlatformId } from '@libid/ceremony' +import { missing, type Need, PLATFORM_SECTIONS, VARIABLES } from './settings.ts' + +const read = (relative: string) => readFileSync(new URL(relative, import.meta.url), 'utf8') +const workflow = read('../../../../.github/workflows/live-e2e.yml') + +/** The jobs that run live tests, and the platforms each proves. */ +const JOBS: [string, PlatformId[]][] = [ + ['ceremony', ['github', 'x']], + ['google-ssm', ['google']], +] + +const isSetting = (name: string) => VARIABLES.some(([setting]) => setting === name) +/** A name of the kind the settings own. */ +const looksLikeSetting = (name: string) => /_TEST_ALICE_/.test(name) || name.startsWith('LIBID_') + +/** An `env:` entry's key and value, if `line` is one. */ +function envEntry(line: string): [string, string] | undefined { + const match = /^\s+([A-Z][A-Z0-9_]*):\s*(.*)$/.exec(line) + return match ? [match[1]!, match[2]!] : undefined +} + +/** Every name `text` reads as `secrets.NAME` or `vars.NAME`. */ +const referenced = (text: string) => + new Set([...text.matchAll(/\b(?:secrets|vars)\.([A-Za-z0-9_]+)/g)].map((match) => match[1]!)) + +/** The lines of job `name`'s test step (`id: test`). */ +function testStep(name: string): string[] { + const lines = workflow.split('\njobs:\n')[1]!.split('\n') + const start = lines.indexOf(` ${name}:`) + assert.notEqual(start, -1, `the workflow has a job ${name}`) + const end = lines.findIndex((line, index) => index > start && /^ {2}[a-z][\w-]*:$/.test(line)) + const job = lines.slice(start, end === -1 ? undefined : end) + const id = job.indexOf(' id: test') + assert.notEqual(id, -1, `job ${name} has a step with id test`) + const isStep = (line: string) => line.startsWith(' - ') + let first = id + while (first > 0 && !isStep(job[first]!)) first-- + const next = job.findIndex((line, index) => index > id && isStep(line)) + return job.slice(first, next === -1 ? undefined : next) +} + +test('every secret the workflow reads is a setting, passed under its own name', () => { + for (const name of [...workflow.matchAll(/\bsecrets\.([A-Za-z0-9_]+)/g)].map((m) => m[1]!)) + assert.ok(isSetting(name), `the workflow reads secret ${name}, which the settings do not list`) + for (const line of workflow.split('\n')) { + const entry = envEntry(line) + if (!entry || !looksLikeSetting(entry[0])) continue + assert.ok(isSetting(entry[0]), `the workflow sets ${entry[0]}, which the settings do not list`) + const read = referenced(entry[1]) + if (read.size) + assert.deepEqual(read, new Set([entry[0]]), `${entry[0]} carries its own setting`) + } +}) + +test("each job's test step is given what its platforms require", () => { + for (const [name, platforms] of JOBS) { + const given = new Set(testStep(name).flatMap((line) => envEntry(line)?.[0] ?? [])) + const lookup = (variable: string) => (given.has(variable) ? 'set' : undefined) + const absent = platforms.flatMap((platform) => + PLATFORM_SECTIONS[platform].flatMap((section) => missing(section, lookup)), + ) + assert.deepEqual(absent, [], `job ${name}'s test step is not given ${absent.join(', ')}`) + } +}) + +test("the README's live tests table lists every setting as the table does", () => { + const rows = read('../README.md') + .split('\n') + .filter((line) => line.startsWith('| `')) + const needText = (need: Need) => + typeof need === 'string' ? need : `instead of \`${need.insteadOf}\`` + for (const [name, section, need] of VARIABLES) { + const row = rows.find((line) => line.startsWith(`| \`${name}\` |`)) + assert.ok(row, `the README lists ${name}`) + assert.ok( + row.startsWith(`| \`${name}\` | ${section} | ${needText(need)} |`), + `the README lists ${name} as in ${section}, ${needText(need)}: ${row}`, + ) + } + for (const row of rows) { + const name = row.slice(3).split('`')[0]! + assert.ok(isSetting(name), `the README lists ${name}, which the settings do not`) + } +}) diff --git a/ts/apps/dev/package.json b/ts/apps/dev/package.json index 14c5cbde..4455f935 100644 --- a/ts/apps/dev/package.json +++ b/ts/apps/dev/package.json @@ -13,6 +13,7 @@ "build:deps": "pnpm --filter '@libid/dev^...' build", "build": "vite build", "typecheck": "tsc --noEmit", + "test": "node --test e2e/settings.test.ts", "test:e2e": "playwright test", "test:live": "playwright test -c playwright.live.config.ts" }, From 38fc71b4ba48bceb5af958021e5f0aa836c9ee81 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 21:10:54 +0300 Subject: [PATCH 052/103] fix(dev): watch the popup's return to Bridge from the moment it appears A platform that needs no consent can send the popup to Bridge's callback before the driver attached its request listener, and the driver then waited out its budget. The spec now starts watchReturn in the `page` event's predicate, which runs as the popup appears, and the driver also treats a popup whose current URL is the callback as returned. A popup that closes after it returned counts as returned. The cold-start "Ready" check waits up to 60 s, so a slow first load of the Bridge configuration fails there by name. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/ceremony.live.spec.ts | 9 +++- ts/apps/dev/e2e/popup.ts | 65 ++++++++++++++++----------- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/ts/apps/dev/e2e/ceremony.live.spec.ts b/ts/apps/dev/e2e/ceremony.live.spec.ts index 754242b0..b7e3e47f 100644 --- a/ts/apps/dev/e2e/ceremony.live.spec.ts +++ b/ts/apps/dev/e2e/ceremony.live.spec.ts @@ -3,6 +3,7 @@ import { type BrowserContext, expect, type Page, test } from '@playwright/test' import { authorizeOnGitHub } from './github.ts' import { authorizeOnGoogle } from './google.ts' import { presentAsPerson } from './person.ts' +import { watchReturn } from './popup.ts' import { googleSession, restoreSession, xSession } from './session.ts' import { githubAccount, @@ -48,9 +49,13 @@ async function proveThroughTheUi( authorize: (popup: Page) => Promise, ) { await page.goto('/') - await expect(page.locator('#status')).toHaveText('Ready. Click a platform to start a ceremony.') + // A cold app loads the Bridge configuration and CCDP's version list first. + await expect(page.locator('#status')).toHaveText('Ready. Click a platform to start a ceremony.', { + timeout: 60_000, + }) const [popup] = await Promise.all([ - context.waitForEvent('page'), + // Watched from the moment it appears: a platform may return it at once. + context.waitForEvent('page', { predicate: (popup) => Boolean(watchReturn(popup)) }), page.getByRole('button', { name, exact: true }).click(), ]) const id = await page.locator('#history tr').first().getAttribute('data-ceremony-id') diff --git a/ts/apps/dev/e2e/popup.ts b/ts/apps/dev/e2e/popup.ts index fd729850..b336292b 100644 --- a/ts/apps/dev/e2e/popup.ts +++ b/ts/apps/dev/e2e/popup.ts @@ -27,6 +27,27 @@ const isCallback = (url: string) => { return parsed?.origin === CALLBACK.origin && parsed.pathname === CALLBACK.pathname } +const returns = new WeakMap() + +/** + * Watch `popup` for its return to Bridge's callback, from now on. Call it as + * the popup appears (in the `page` event's predicate): a platform that needs + * no consent redirects at once. A request, not a committed page, counts: the + * callback may redirect on at once. + */ +export function watchReturn(popup: Page): { returned: boolean } { + let watch = returns.get(popup) + if (!watch) { + const state = { returned: isCallback(popup.url()) } + popup.on('request', (request: Request) => { + if (request.isNavigationRequest() && isCallback(request.url())) state.returned = true + }) + returns.set(popup, state) + watch = state + } + return watch +} + /** * 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 @@ -35,12 +56,8 @@ const isCallback = (url: string) => { */ 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: Request) => { - if (request.isNavigationRequest() && isCallback(request.url())) returned = true - } - popup.on('request', onRequest) + const watch = watchReturn(popup) + const returned = () => watch.returned || (!popup.isClosed() && isCallback(popup.url())) const pause: Pause = async (ms, reason) => { if (Date.now() + ms > deadline) throw new Error( @@ -48,28 +65,24 @@ export async function drivePopup(popup: Page, driver: Driver) { ) 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 visible = await popup - .locator('body') - .innerText({ timeout: 2_000 }) - .catch(() => '') - const text = visible.split(/\s+/).join(' ').trim().toLowerCase().replaceAll('’', "'") - await driver.step(url, text, pause) - } - await sleep(250) + 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 visible = await popup + .locator('body') + .innerText({ timeout: 2_000 }) + .catch(() => '') + const text = visible.split(/\s+/).join(' ').trim().toLowerCase().replaceAll('’', "'") + await driver.step(url, text, pause) } - } finally { - popup.off('request', onRequest) + await sleep(250) } - if (returned) return + if (returned()) return throw new Error( `No ${driver.name} authorization in ${BUDGET_MS / 1000} s; stopped on ${popup.url()}`, ) From e674d8b9ca6d4d9168b2ba37b77769861f9709ba Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 21:10:54 +0300 Subject: [PATCH 053/103] fix(dev): restore saved sessions as the Rust export defines them The mapping follows libid-server-rs' ceremony-tests src/browser/cookies.rs: a domain cookie (leading dot) is set on its domain and path; a host-only or __Host- one by url alone, since it may carry no Domain attribute; a partition key, CDP's object or the bare site, is kept; `expirationDate` reads as `expires`; an expired cookie is left out. Playwright's public API takes the partition's top-level site only, so hasCrossSiteAncestor takes its default. cookieParams is the pure mapper restoreSession applies; session.test.ts feeds it a synthetic export in each spelling (no secrets), and runs in `pnpm --filter @libid/dev test`. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/session.test.ts | 88 +++++++++++++++++++++++++++++++++ ts/apps/dev/e2e/session.ts | 78 ++++++++++++++++++++++------- ts/apps/dev/package.json | 2 +- 3 files changed, 149 insertions(+), 19 deletions(-) create mode 100644 ts/apps/dev/e2e/session.test.ts diff --git a/ts/apps/dev/e2e/session.test.ts b/ts/apps/dev/e2e/session.test.ts new file mode 100644 index 00000000..d399a0e7 --- /dev/null +++ b/ts/apps/dev/e2e/session.test.ts @@ -0,0 +1,88 @@ +// A synthetic `ceremony export` through the mapping restoreSession applies. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { cookieParams, type Stored } from './session.ts' + +const NOW = 1_800_000_000 + +const exported: Stored[] = [ + // CDP's camelCase, a domain cookie, with a partition as CDP reports it. + { + name: 'auth_token', + value: 'v1', + domain: '.x.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'no_restriction', + expires: NOW + 60, + partitionKey: { topLevelSite: 'https://x.com', hasCrossSiteAncestor: false }, + }, + // snake_case, host-only, no path, a session cookie, partitioned by site alone. + { + name: 'ct0', + value: 'v2', + domain: 'x.com', + http_only: false, + same_site: 'Lax', + expires: 0, + partition_key: 'https://x.com', + }, + // A __Host- cookie, unpartitioned, with `expirationDate`. + { name: '__Host-GAPS', value: 'v3', domain: 'accounts.google.com', expirationDate: NOW + 60 }, + // Expired: left out. + { name: 'old', value: 'v4', domain: '.x.com', expires: NOW - 1 }, +] + +test('a domain cookie keeps its domain, path and partition', () => { + assert.deepEqual(cookieParams(exported, undefined, NOW)[0], { + name: 'auth_token', + value: 'v1', + domain: '.x.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'None', + expires: NOW + 60, + partitionKey: 'https://x.com', + }) +}) + +test('host-only and __Host- cookies are placed by url alone', () => { + const [, hostOnly, host] = cookieParams(exported, undefined, NOW) + assert.deepEqual(hostOnly, { + name: 'ct0', + value: 'v2', + url: 'https://x.com/', + secure: true, + httpOnly: false, + sameSite: 'Lax', + expires: -1, + partitionKey: 'https://x.com', + }) + assert.equal(host?.url, 'https://accounts.google.com/') + assert.equal(host && 'domain' in host, false) + assert.equal(host?.expires, NOW + 60) + assert.equal(host && 'partitionKey' in host, false) +}) + +test('expired cookies are left out; each cookie goes on every domain given', () => { + const params = cookieParams( + exported, + (domain) => [domain, domain.replace('x.com', 'twitter.com')], + NOW, + ) + assert.equal(params.length, 6) + assert.ok(!params.some((param) => param.name === 'old')) + assert.deepEqual( + params.map((param) => param.domain ?? param.url), + [ + '.x.com', + '.twitter.com', + 'https://x.com/', + 'https://twitter.com/', + 'https://accounts.google.com/', + 'https://accounts.google.com/', + ], + ) +}) diff --git a/ts/apps/dev/e2e/session.ts b/ts/apps/dev/e2e/session.ts index c47eb9f4..69e11057 100644 --- a/ts/apps/dev/e2e/session.ts +++ b/ts/apps/dev/e2e/session.ts @@ -3,10 +3,15 @@ import type { BrowserContext } from '@playwright/test' import * as settings from './settings.ts' import type { SavedSession } from './settings.ts' -/** One cookie as libid-server-rs' `ceremony export` saves it; both key casings occur. */ +/** + * One cookie as libid-server-rs' `ceremony export` saves it (ceremony-tests + * src/browser/cookies.rs): snake_case or camelCase keys, `expirationDate` for + * `expires`, and a partition key as CDP's object or its top-level site alone. + */ export interface Stored { name: string value: string + /** With a leading dot for a domain cookie; the host alone for a host-only one. */ domain: string path?: string secure?: boolean | null @@ -15,8 +20,23 @@ export interface Stored { same_site?: string | null sameSite?: string | null expires?: number + expirationDate?: number + partition_key?: PartitionKey | string | null + partitionKey?: PartitionKey | string | null } +interface PartitionKey { + topLevelSite?: string + top_level_site?: string + hasCrossSiteAncestor?: boolean +} + +/** The JSON text of a saved session setting. */ +export const exportText = (saved: SavedSession) => + 'encoded' in saved + ? Buffer.from(saved.encoded.trim(), 'base64').toString('utf8') + : readFileSync(saved.path, 'utf8') + /** * The cookies of a saved session setting, or undefined when it is not set. * `required` names cookies of which at least one must be present for the @@ -28,10 +48,7 @@ function cookiesOf( required: string[], ): Stored[] | undefined { if (!saved) return undefined - const json = - 'encoded' in saved - ? Buffer.from(saved.encoded.trim(), 'base64').toString('utf8') - : readFileSync(saved.path, 'utf8') + const json = exportText(saved) let cookies: Stored[] | undefined try { const parsed = JSON.parse(json) as Stored[] | { cookies?: Stored[] } @@ -75,24 +92,49 @@ const SAME_SITE = new Map([ ['no_restriction', 'None'], ]) -/** Put saved cookies in the browser context, on their own domain or each of `domainsOf`'s. */ -export async function restoreSession( - context: BrowserContext, +type CookieParam = Parameters[0][number] + +/** + * The cookies to set for a saved session, as the Rust suite sets them: a + * domain cookie on its domain, a host-only or `__Host-` one by `url` alone, + * each in its partition; an expired one is left out. `domainsOf` gives the + * domains each cookie goes on. + */ +export function cookieParams( cookies: Stored[], domainsOf: (domain: string) => string[] = (domain) => [domain], -) { - await context.addCookies( - cookies.flatMap((cookie) => - domainsOf(cookie.domain).map((domain) => ({ + now = Date.now() / 1000, +): CookieParam[] { + return cookies.flatMap((cookie) => { + const expires = cookie.expires ?? cookie.expirationDate ?? 0 + if (expires > 0 && expires < now) return [] + const key = cookie.partition_key ?? cookie.partitionKey + const partitionKey = typeof key === 'string' ? key : (key?.topLevelSite ?? key?.top_level_site) + return domainsOf(cookie.domain).map((domain): CookieParam => { + const path = cookie.path || '/' + // Playwright takes a url's path up to its last slash. + const placed = domain.startsWith('.') + ? { domain, path } + : { url: `https://${domain}${path.endsWith('/') ? path : `${path}/`}` } + return { name: cookie.name, value: cookie.value, - domain, - path: cookie.path || '/', + ...placed, secure: cookie.secure ?? true, httpOnly: cookie.http_only ?? cookie.httpOnly ?? false, sameSite: SAME_SITE.get((cookie.same_site ?? cookie.sameSite ?? '').toLowerCase()), - expires: cookie.expires && cookie.expires > 0 ? cookie.expires : -1, - })), - ), - ) + expires: expires > 0 ? expires : -1, + ...(partitionKey && { partitionKey }), + } + }) + }) +} + +/** Put saved cookies in the browser context, on their own domain or each of `domainsOf`'s. */ +export async function restoreSession( + context: BrowserContext, + cookies: Stored[], + domainsOf?: (domain: string) => string[], +) { + await context.addCookies(cookieParams(cookies, domainsOf)) } diff --git a/ts/apps/dev/package.json b/ts/apps/dev/package.json index 4455f935..088f78e3 100644 --- a/ts/apps/dev/package.json +++ b/ts/apps/dev/package.json @@ -13,7 +13,7 @@ "build:deps": "pnpm --filter '@libid/dev^...' build", "build": "vite build", "typecheck": "tsc --noEmit", - "test": "node --test e2e/settings.test.ts", + "test": "node --test e2e/settings.test.ts e2e/session.test.ts", "test:e2e": "playwright test", "test:live": "playwright test -c playwright.live.config.ts" }, From 5dc6798a0a8df4f3270e48b1dab7b793aeadc3fd Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 21:10:54 +0300 Subject: [PATCH 054/103] fix(dev): scan for a session's secrets even when it does not decode A malformed or incomplete saved session made the scan throw, which blocked the failure upload. The scan now looks for the setting's own value, then its cookies' values from the JSON where it parses, else every "value" string in its text; it never throws on a session, and still fails on a secret it finds. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/scan-secrets.ts | 41 +++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/ts/apps/dev/e2e/scan-secrets.ts b/ts/apps/dev/e2e/scan-secrets.ts index 7660e038..a89e602e 100644 --- a/ts/apps/dev/e2e/scan-secrets.ts +++ b/ts/apps/dev/e2e/scan-secrets.ts @@ -1,15 +1,42 @@ // Refuse to publish a failed run's stack log when it holds a test secret: the -// GitHub password or TOTP secret, or a value of a saved session. Paths are the -// arguments; a missing file is skipped. Nothing secret is printed. +// GitHub password or TOTP secret, a saved session as set, or a value of its +// cookies. A session that does not decode is still looked for as set. Paths +// are the arguments; a missing file is skipped. Nothing secret is printed. import { existsSync, readFileSync } from 'node:fs' -import { googleSession, xSession } from './session.ts' -import { githubSecrets } from './settings.ts' +import { exportText } from './session.ts' +import { githubSecrets, googleSession, type SavedSession, xSession } from './settings.ts' + +/** + * The session setting's own value, and its cookies' values: from the JSON + * where it parses, else every `"value": "…"` string its text holds. + */ +function sessionSecrets(saved: SavedSession | undefined): string[] { + if (!saved) return [] + const raw = 'encoded' in saved ? [saved.encoded.trim()] : [] + let text: string + try { + text = exportText(saved) + } catch { + return raw + } + try { + const parsed: unknown = JSON.parse(text) + const cookies = Array.isArray(parsed) ? parsed : (parsed as { cookies?: unknown })?.cookies + if (Array.isArray(cookies)) + return [...raw, ...cookies.map((cookie) => (cookie as { value?: unknown })?.value)].filter( + (value): value is string => typeof value === 'string', + ) + } catch { + // Not JSON: fall back to the value strings in the text. + } + return [...raw, ...[...text.matchAll(/"value"\s*:\s*"([^"\\]*)"/g)].map((match) => match[1]!)] +} -const sessions = [xSession(), googleSession()] const secrets = [ ...githubSecrets(), - ...sessions.flatMap((cookies) => cookies?.map((cookie) => cookie.value) ?? []), -].filter((secret): secret is string => !!secret && secret.length >= 8) + ...sessionSecrets(xSession()), + ...sessionSecrets(googleSession()), +].filter((secret) => secret.length >= 8) const leaking = process.argv .slice(2) From 58f9cf3742bfdd468a3eb20896861a9ee546f82c Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 21:10:54 +0300 Subject: [PATCH 055/103] fix(dev): start the live run's own stack, with bounded steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The live config no longer reuses a running server: a reused stack bypassed services.ts' port check and could be another checkout's. The README says the run needs the ports free. - actionTimeout 30 s and navigationTimeout 60 s, so a hang ends on the step that hangs rather than the 12-minute test timeout. - The env file merges through settings.ts: an empty exported value counts as absent, as the readers treat it. - services.ts names this checkout's own Compose project and the exact `docker compose -p … down` when that project holds the ports, and its port probe times out after a second. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/README.md | 4 ++-- ts/apps/dev/e2e/settings.ts | 13 +++++++++++++ ts/apps/dev/playwright.live.config.ts | 19 +++++++++---------- ts/apps/dev/services.ts | 14 +++++++++++++- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/ts/apps/dev/README.md b/ts/apps/dev/README.md index 11f97e13..bff41723 100644 --- a/ts/apps/dev/README.md +++ b/ts/apps/dev/README.md @@ -109,8 +109,8 @@ without fallback. Direct runs have no fallback row. ## Live tests The live suite proves real GitHub, X and Google accounts from this app's UI, -with the services above. It starts `pnpm dev`, or reuses one already running, -and refuses to start beside another stack on the same ports. +with the services above. It starts its own `pnpm dev` and stops it after, so +the ports above and the app's must be free: stop a running `pnpm dev` first. ```sh LIBID_TEST_ENV_FILE=path/to/.env.test pnpm --filter @libid/dev test:live diff --git a/ts/apps/dev/e2e/settings.ts b/ts/apps/dev/e2e/settings.ts index f3a41111..f43587b2 100644 --- a/ts/apps/dev/e2e/settings.ts +++ b/ts/apps/dev/e2e/settings.ts @@ -2,7 +2,9 @@ // the section needs it, in VARIABLES; one typed reader per section. The names // are those of libid-server-rs' ceremony-tests (src/settings.rs), which reads // the same .env.test. An empty value is an absent one. +import { readFileSync } from 'node:fs' import { dirname, isAbsolute, join, resolve } from 'node:path' +import { parseEnv } from 'node:util' import type { PlatformId } from '@libid/ceremony' /** Whether a section needs a variable; `insteadOf` stands in for that required one. */ @@ -65,6 +67,17 @@ export function envFile(get: Lookup = environment): string | undefined { return file && resolve(get('INIT_CWD') ?? process.cwd(), file) } +/** + * Read the settings file into the environment: a value already set wins, and + * an empty one counts as absent. Returns the file's keys. + */ +export function loadEnvFile(file: string): string[] { + const values = parseEnv(readFileSync(file, 'utf8')) + for (const [name, value] of Object.entries(values)) + if (environment(name) === undefined) process.env[name] = value + return Object.keys(values) +} + export interface Tooling { /** LIBID_LIVE_PLATFORMS, trimmed and checked, or every supported platform. */ platforms: readonly PlatformId[] diff --git a/ts/apps/dev/playwright.live.config.ts b/ts/apps/dev/playwright.live.config.ts index 607f560e..2ceea999 100644 --- a/ts/apps/dev/playwright.live.config.ts +++ b/ts/apps/dev/playwright.live.config.ts @@ -1,18 +1,14 @@ -import { readFileSync } from 'node:fs' -import { parseEnv } from 'node:util' import { defineConfig, devices } from '@playwright/test' -import { checkSettings, envFile, tooling, unknownKeys } from './e2e/settings.ts' +import { checkSettings, envFile, loadEnvFile, tooling, unknownKeys } from './e2e/settings.ts' 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. The runner process +// set in the environment win. Nothing is printed. The runner process // checks them once; its workers load this file too. const runner = process.env.TEST_WORKER_INDEX === undefined const file = envFile() if (file) { - const values = parseEnv(readFileSync(file, 'utf8')) - for (const [name, value] of Object.entries(values)) process.env[name] ??= value - const typos = unknownKeys(Object.keys(values)) + const typos = unknownKeys(loadEnvFile(file)) if (runner && typos.length) console.warn(`${file}: no live test reads ${typos.join(', ')}; check the spelling.`) } @@ -27,12 +23,12 @@ export default defineConfig({ // 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 services and the app, test, then stop both. A - // `pnpm dev` already running locally is reused. + // One command: start the services and the app, test, then stop both. The + // run never reuses a running stack: services.ts refuses taken ports. webServer: { command: 'pnpm dev', url: APP_ORIGIN, - reuseExistingServer: !process.env.CI, + reuseExistingServer: false, timeout: 10 * 60_000, gracefulShutdown: { signal: 'SIGTERM', timeout: 120_000 }, // CI keeps the services' output off the public job log: services.ts @@ -43,6 +39,9 @@ export default defineConfig({ ...devices['Desktop Chrome'], baseURL: APP_ORIGIN, headless: !process.env.HEADED, + // A hang ends on the action that hangs, not on the test's timeout. + actionTimeout: 30_000, + navigationTimeout: 60_000, // Automation X and Google can see makes them challenge; see e2e/person.ts. launchOptions: { args: ['--disable-blink-features=AutomationControlled'] }, // LIBID_TEST_PROXY routes the browser's traffic, loopback aside, through a diff --git a/ts/apps/dev/services.ts b/ts/apps/dev/services.ts index 3b5e44d8..1dabfaae 100644 --- a/ts/apps/dev/services.ts +++ b/ts/apps/dev/services.ts @@ -20,17 +20,23 @@ const stdio = /** Whether something on this host accepts connections on `port`. */ const listening = (port: number) => new Promise((resolve) => { - const socket = connect(port, '127.0.0.1') + const socket = connect({ port, host: '127.0.0.1', timeout: 1000 }) socket.once('connect', () => { socket.destroy() resolve(true) }) + socket.once('timeout', () => { + socket.destroy() + resolve(false) + }) socket.once('error', () => resolve(false)) }) // Refuse to start beside another stack on compose.yaml's ports: readiness could pass against it. if (!process.argv.includes('--ccdp')) { let ports: number[] + let own: string try { + own = execFileSync('docker', [...composeArgs, 'ps', '-q'], { encoding: 'utf8' }).trim() const config = JSON.parse( execFileSync('docker', [...composeArgs, 'config', '--format', 'json'], { encoding: 'utf8' }), ) as { services: Record } @@ -43,6 +49,12 @@ if (!process.argv.includes('--ccdp')) { } const open = await Promise.all(ports.map(listening)) const taken = ports.filter((_, index) => open[index]) + if (taken.length && own) { + console.error( + `This checkout's stack already runs (Compose project ${project}): stop that \`pnpm dev\`, or run \`docker compose -p ${project} down\`.`, + ) + process.exit(1) + } if (taken.length) { console.error( `Already in use on 127.0.0.1: port ${taken.join(', ')}. Stop what holds it (see docker ps) and retry.`, From f16c689ff550003d4f7d0bb161313e6720277bcf Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 21:10:54 +0300 Subject: [PATCH 056/103] refactor(dev): take the notary origin and fixture config URL from local.ts Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/local.ts | 3 ++- ts/apps/dev/src/app.fixtures.ts | 3 ++- ts/apps/dev/src/app.ts | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ts/apps/dev/local.ts b/ts/apps/dev/local.ts index 5fb0c8e4..c74d7836 100644 --- a/ts/apps/dev/local.ts +++ b/ts/apps/dev/local.ts @@ -1,6 +1,7 @@ -// The origins the app, services.ts and the live tests share. compose.yaml and +// The origins the app, services.ts and the tests share. compose.yaml and // bridge-config.toml repeat the ports they need. /** The app, served by Vite; bridge-config.toml admits this origin. */ export const APP_ORIGIN = 'http://localhost:4691' export const BRIDGE = 'http://localhost:4682' +export const NOTARY = 'http://localhost:4687' diff --git a/ts/apps/dev/src/app.fixtures.ts b/ts/apps/dev/src/app.fixtures.ts index f6682028..7bfb1655 100644 --- a/ts/apps/dev/src/app.fixtures.ts +++ b/ts/apps/dev/src/app.fixtures.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { type BrowserContext, expect, type Locator, type Page } from '@playwright/test' import { buildGooglePublicInputs } from '../../../packages/ceremony/src/platforms/google/1/validation.js' +import { BRIDGE } from '../local.ts' // Helpers for app.spec.ts: the Bridge and app, the run history, and the popup-side fixtures. @@ -22,7 +23,7 @@ declare global { } } -export const configUrl = 'http://localhost:4682/api/v1/ceremony/config' +export const configUrl = new URL('/api/v1/ceremony/config', BRIDGE).href // The Bridge selects CCDP, so tests use their own origin, apart from the development one. export const ccdp = 'http://localhost:4684' export const config = { diff --git a/ts/apps/dev/src/app.ts b/ts/apps/dev/src/app.ts index d18e6077..25771733 100644 --- a/ts/apps/dev/src/app.ts +++ b/ts/apps/dev/src/app.ts @@ -10,7 +10,7 @@ import { import type { LedgerId } from '@libid/ledger' import { PopupWindow } from '@libid/popup' import { sha256 } from '@noble/hashes/sha2.js' -import { BRIDGE } from '../local.ts' +import { BRIDGE, NOTARY } from '../local.ts' declare global { interface Window { @@ -20,7 +20,7 @@ declare global { const oauthBridge = BRIDGE /** A synthetic ledger routed to the local notary. */ const ledger: LedgerId = { - notaryAddress: () => 'http://localhost:4687', + notaryAddress: () => NOTARY, } const platforms = document.querySelector('#platforms')! const status = document.querySelector('#status')! From 3411bb98eaeac063678323acc6b96e9bc48ece0f Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 21:10:54 +0300 Subject: [PATCH 057/103] ci(live-e2e): fit the step timeout to every platform; exclude Dependabot - The ceremony test step's timeout covers all three platforms the variable can select: 10 + 3 x 12 + 2 = 48 minutes; the step gets 55, the job 65. - The SSM Google job skips Dependabot's pull requests, as the ceremony job does. - The path filter includes the two local actions the jobs use. - The failure-output action drops a dead LIBID_REQUIRE_LIVE and pins upload-artifact to ci.yml's SHA (v7.0.2). Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/actions/live-e2e-output/action.yml | 5 +---- .github/workflows/live-e2e.yml | 18 +++++++++++------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/actions/live-e2e-output/action.yml b/.github/actions/live-e2e-output/action.yml index dbad81ff..e65c4f54 100644 --- a/.github/actions/live-e2e-output/action.yml +++ b/.github/actions/live-e2e-output/action.yml @@ -20,9 +20,6 @@ runs: # in it fails this step, and nothing goes up. - name: Check the stack log for secrets shell: bash - env: - # A session that is not configured is nothing to look for. - LIBID_REQUIRE_LIVE: "" run: | docker compose ls -q --all --filter name=libid-dev | xargs -r -I{} docker compose -p {} down --volumes @@ -30,7 +27,7 @@ runs: node ts/apps/dev/e2e/scan-secrets.ts "$RUNNER_TEMP/stack-scanned.log" - name: Keep the screenshots and the stack log - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ inputs.name }} path: | diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 5077396b..6f658f53 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -12,6 +12,8 @@ on: paths: - "ts/**" - ".github/workflows/live-e2e.yml" + - ".github/actions/live-e2e-output/**" + - ".github/actions/setup-ts/**" workflow_dispatch: schedule: - cron: "23 3 * * *" @@ -38,7 +40,7 @@ jobs: github.actor != 'dependabot[bot]') runs-on: ubuntu-latest # The test step's budget plus setup, the secret scan and the upload. - timeout-minutes: 50 + timeout-minutes: 65 env: # A missing secret fails the run instead of skipping the test. LIBID_REQUIRE_LIVE: "1" @@ -56,12 +58,13 @@ jobs: # GitHub and X. Google is left out by default: it has challenged a saved # session arriving from a runner's address ("Verify it's you"), which no # automation passes. The google-ssm job runs it through a proxy. - # Its own timeout, so a run that hangs still reaches the steps below: - # the stack's 10-minute start, two tests of 12 minutes, and 2 minutes - # to stop the stack (playwright.live.config.ts), with room to spare. + # Its own timeout, so a run that hangs still reaches the step below: the + # stack's 10-minute start, up to three tests of 12 minutes (all the + # variable can select), and 2 minutes to stop the stack + # (playwright.live.config.ts): 48 minutes, with room to spare. - name: Prove identities end to end id: test - timeout-minutes: 40 + timeout-minutes: 55 env: LIBID_LIVE_PLATFORMS: ${{ vars.LIBID_LIVE_PLATFORMS || 'github,x' }} LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log @@ -105,7 +108,8 @@ jobs: vars.LIBID_GOOGLE_RUNNER == 'ssm' && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || (github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name == github.repository)) + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]')) runs-on: ubuntu-latest # The test step's budget plus setup, the proxy, the secret scan and the upload. timeout-minutes: 45 @@ -149,7 +153,7 @@ jobs: # Its own timeout, so a run that hangs still reaches the step below: the # stack's 10-minute start, one test of 12 minutes, and 2 minutes to stop - # the stack (playwright.live.config.ts), with room to spare. + # the stack (playwright.live.config.ts): 24 minutes, with room to spare. - name: Prove a Google identity end to end id: test timeout-minutes: 30 From 6e96c38d9eeaf58d3d26e73824d487c70d012d4c Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 21:35:17 +0300 Subject: [PATCH 058/103] fix(dev): fail fast on a bad TOTP key or a refused GitHub sign-in - The TOTP code is generated outside the step's retry: a key that is not base32 fails at once, naming GH_TEST_ALICE_TOTP_SECRET and not its value. - A login form still showing 15 s after the submit is GitHub refusing the sign-in: the driver fails with the page's flash message, up to 200 characters, instead of waiting out its budget. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/github.ts | 43 ++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/ts/apps/dev/e2e/github.ts b/ts/apps/dev/e2e/github.ts index da743dad..10080e08 100644 --- a/ts/apps/dev/e2e/github.ts +++ b/ts/apps/dev/e2e/github.ts @@ -5,22 +5,25 @@ import type { GitHubAccount } from './settings.ts' /** The longest a fill or click may take; a navigation gets twice that. */ const STEP_TIMEOUT = 5_000 +/** How long a submitted login form may still show before it counts as refused. */ +const LOGIN_SETTLE = 15_000 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() + let key: OTPAuth.Secret + try { + key = OTPAuth.Secret.fromBase32(secret.replace(/\s/g, '').toUpperCase()) + } catch { + throw new Error('GH_TEST_ALICE_TOTP_SECRET is not a base32 key') + } + return new OTPAuth.TOTP({ secret: key, algorithm: 'SHA1', digits: 6, period: 30 }).generate() } /** 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 + /** When the login form was last submitted. */ + filledAt?: number totpStep?: number /** When the authorize button was last clicked; it is not clicked again within 5 s. */ authorizedAt: number @@ -48,7 +51,7 @@ async function onErrorPage(popup: Page, progress: Progress, pause: Pause) { // Reopening the request keeps the popup on github.com until GitHub redirects. if (progress.start) await popup.goto(progress.start, { timeout: STEP_TIMEOUT * 2 }).catch(() => {}) - progress.filled = false + progress.filledAt = undefined progress.totpStep = undefined } @@ -62,12 +65,24 @@ async function onLogin( if (text.includes('too many requests') || text.includes('rate limit')) { await backOff(progress, 'rateLimited', 30_000, 'GitHub rate-limited the login', pause) await popup.reload({ timeout: STEP_TIMEOUT * 2 }).catch(() => {}) - progress.filled = false + progress.filledAt = undefined return } if (text.includes('incorrect username or password')) throw new Error('GitHub refused the username or password: check GH_TEST_ALICE_*') - if (progress.filled || !(await popup.locator('#login_field').count())) return + if (!(await popup.locator('#login_field').count())) return + // The form still showing well after a submit is GitHub refusing the sign-in. + if (progress.filledAt !== undefined) { + if (Date.now() - progress.filledAt < LOGIN_SETTLE) return + const flash = await popup + .locator('.flash-error, .flash-warn, #js-flash-container') + .first() + .innerText({ timeout: 1_000 }) + .catch(() => '') + throw new Error( + `GitHub showed the login form again after sign-in: ${flash.split(/\s+/).join(' ').trim().slice(0, 200) || 'no message'}`, + ) + } // A step that fails is tried again on the next poll, inside drivePopup's budget. try { await popup.fill('#login_field', account.username, { timeout: STEP_TIMEOUT }) @@ -76,14 +91,15 @@ async function onLogin( } catch { return } - progress.filled = true + progress.filledAt = Date.now() } 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 + const code = totp(account.totpSecret) try { - await popup.fill('#app_totp', totp(account.totpSecret), { timeout: STEP_TIMEOUT }) + await popup.fill('#app_totp', code, { timeout: STEP_TIMEOUT }) } catch { return } @@ -159,7 +175,6 @@ async function onGitHubPage( */ export async function authorizeOnGitHub(popup: Page, account: GitHubAccount) { const progress: Progress = { - filled: false, authorizedAt: 0, skippedCheckupAt: 0, rateLimited: 0, From 72c6ed978d219499699f0942e5e35633fdbb1c6b Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 21:35:17 +0300 Subject: [PATCH 059/103] fix(dev): bound every popup driver step by the driver's budget A driver step now races the rest of drivePopup's budget, and the page evaluations in the X and Google drivers and clickFound give up after 5 s, so a hung page still ends with "stopped on ". An abandoned step's later rejection is handled. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/google.ts | 4 ++-- ts/apps/dev/e2e/popup.ts | 19 +++++++++++++++++-- ts/apps/dev/e2e/x.ts | 4 ++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/ts/apps/dev/e2e/google.ts b/ts/apps/dev/e2e/google.ts index ebf8b285..ea684aff 100644 --- a/ts/apps/dev/e2e/google.ts +++ b/ts/apps/dev/e2e/google.ts @@ -1,5 +1,5 @@ import type { Page } from '@playwright/test' -import { clickFound, drivePopup } from './popup.ts' +import { clickFound, drivePopup, evaluateWithin } from './popup.ts' /** Pages that no automation gets past: the session or the OAuth client needs a person. */ const REFUSED: [string, string][] = [ @@ -41,7 +41,7 @@ export async function authorizeOnGoogle(popup: Page, email: string) { 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)) + if (await evaluateWithin(popup, LOGIN_VISIBLE, false)) 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).', ) diff --git a/ts/apps/dev/e2e/popup.ts b/ts/apps/dev/e2e/popup.ts index b336292b..02d1b09e 100644 --- a/ts/apps/dev/e2e/popup.ts +++ b/ts/apps/dev/e2e/popup.ts @@ -78,7 +78,17 @@ export async function drivePopup(popup: Page, driver: Driver) { .innerText({ timeout: 2_000 }) .catch(() => '') const text = visible.split(/\s+/).join(' ').trim().toLowerCase().replaceAll('’', "'") - await driver.step(url, text, pause) + // A step that outlasts the budget is left behind: the error names the page. + const budget = new AbortController() + const step = driver.step(url, text, pause) + step.catch(() => {}) + const late = await Promise.race([ + step.then(() => false), + sleep(Math.max(0, deadline - Date.now()), true, { signal: budget.signal }).catch( + () => false, + ), + ]).finally(() => budget.abort()) + if (late) break } await sleep(250) } @@ -88,6 +98,11 @@ export async function drivePopup(popup: Page, driver: Driver) { ) } +/** Evaluate `expression` in `popup`, or `fallback` when it fails or takes over 5 s. */ +export function evaluateWithin(popup: Page, expression: string, fallback: T): Promise { + return Promise.race([popup.evaluate(expression).catch(() => fallback), sleep(5_000, fallback)]) +} + /** * Evaluate `find`, a page expression for the control to press or nothing, * then click that control for real. Returns whether there was one. @@ -100,7 +115,7 @@ export async function clickFound(popup: Page, find: string): Promise { target.setAttribute('data-libid-click', '1'); return true; })()` - if (!(await popup.evaluate(mark).catch(() => false))) return false + if (!(await evaluateWithin(popup, mark, false))) return false await popup .locator('[data-libid-click="1"]') .click({ timeout: 5_000 }) diff --git a/ts/apps/dev/e2e/x.ts b/ts/apps/dev/e2e/x.ts index d37c6288..8d6f2833 100644 --- a/ts/apps/dev/e2e/x.ts +++ b/ts/apps/dev/e2e/x.ts @@ -1,5 +1,5 @@ import type { BrowserContext, Page } from '@playwright/test' -import { clickFound, drivePopup } from './popup.ts' +import { clickFound, drivePopup, evaluateWithin } 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. */ @@ -52,7 +52,7 @@ export async function authorizeOnX(popup: Page) { ) } else if (url.pathname.startsWith('/i/oauth2/authorize')) { challengedAt = undefined - await popup.evaluate(ACCEPT_COOKIES).catch(() => {}) + await evaluateWithin(popup, ACCEPT_COOKIES, false) if (Date.now() - consentedAt > 10_000 && (await clickFound(popup, FIND_CONSENT))) consentedAt = Date.now() } From 3d31225fd018a1c6e402661442edd23b9d6b5caf Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 21:35:17 +0300 Subject: [PATCH 060/103] test(dev): check the proof by field name; fit the live test timeout - The spec checks the proof each platform's result names: bearerLinkProof for GitHub and X, identityProof for Google, instead of any non-empty byte field. - The test timeout covers the spec's own budgets: page load 60 s, Ready 60 s, the popup click 30 s, the driver 180 s and the outcome 8 min, 13.5 minutes; it is 15. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/ceremony.live.spec.ts | 18 ++++++++++++++---- ts/apps/dev/playwright.live.config.ts | 6 ++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/ts/apps/dev/e2e/ceremony.live.spec.ts b/ts/apps/dev/e2e/ceremony.live.spec.ts index b7e3e47f..858f448b 100644 --- a/ts/apps/dev/e2e/ceremony.live.spec.ts +++ b/ts/apps/dev/e2e/ceremony.live.spec.ts @@ -22,6 +22,13 @@ type Name = 'GitHub' | 'X' | 'Google' const platformOf: Record = { GitHub: 'github', X: 'x', Google: 'google' } +/** The proof each platform's result carries: GitHub's and X's bearer link, Google's ZK proof. */ +const proofField: Record = { + GitHub: 'bearerLinkProof', + X: 'bearerLinkProof', + Google: 'identityProof', +} + /** What the test reads of a run's entry in window.results. */ interface Outcome { status: string @@ -30,7 +37,8 @@ interface Outcome { userName?: string idNodeBytes?: number handleNodeBytes?: number - proofPresent?: boolean + /** The byte length of each Uint8Array field of the proof, by name. */ + proofBytes?: Record ceremonyVersion?: string expiresAt?: number } @@ -76,8 +84,10 @@ async function proveThroughTheUi( userName: identity.userName, idNodeBytes: identity.idNode.length, handleNodeBytes: identity.handleNode.length, - proofPresent: Object.values(oauthProof.proof).some( - (value) => value instanceof Uint8Array && value.length > 0, + proofBytes: Object.fromEntries( + Object.entries(oauthProof.proof).flatMap(([field, value]) => + value instanceof Uint8Array ? [[field, value.length]] : [], + ), ), ceremonyVersion: String(oauthProof.platformCeremonyVersion), expiresAt: oauthProof.expiresAt, @@ -97,7 +107,7 @@ async function proveThroughTheUi( expect(outcome.userName?.toLowerCase()).toBe(expectedUser.toLowerCase()) expect(outcome.idNodeBytes).toBe(32) expect(outcome.handleNodeBytes).toBe(32) - expect(outcome.proofPresent).toBe(true) + expect(outcome.proofBytes?.[proofField[name]]).toBeGreaterThan(0) expect(outcome.ceremonyVersion).toMatch(/^[1-9]\d*$/) expect(outcome.expiresAt).toBeGreaterThan(Date.now() / 1000) } diff --git a/ts/apps/dev/playwright.live.config.ts b/ts/apps/dev/playwright.live.config.ts index 2ceea999..865161bb 100644 --- a/ts/apps/dev/playwright.live.config.ts +++ b/ts/apps/dev/playwright.live.config.ts @@ -20,8 +20,10 @@ export default defineConfig({ testDir: 'e2e', testMatch: '*.live.spec.ts', workers: 1, - // Each test: the popup driver's 3 minutes and the page's 8-minute outcome wait. - timeout: 12 * 60_000, + // Each test's own budgets: the page load (60 s), the app's Ready (60 s), + // the click that opens the popup (30 s), the popup driver (180 s) and the + // outcome wait (8 min): 13.5 minutes, with margin. + timeout: 15 * 60_000, reporter: [['list']], // One command: start the services and the app, test, then stop both. The // run never reuses a running stack: services.ts refuses taken ports. From cfa8936283089d50b8b2c463c1c30a0be9230859 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 21:35:17 +0300 Subject: [PATCH 061/103] fix(dev): keep a host-only cookie's exact path; name the partition loss Playwright derives a url cookie's path up to the url's last slash, so a host-only cookie at /i lost its path. A host-only cookie now goes on its host without a dot, with its path, which Chromium keeps host-only; a __Host- one stays placed by url, as cookies.rs places it. Checked by feeding the mapping's output to Chromium and reading the cookies back. Playwright's API takes a partition's top-level site only; the comment and the test now say the export's hasCrossSiteAncestor is dropped rather than asserting the mapping as the producer's meaning. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/session.test.ts | 29 +++++++++++++++++++---------- ts/apps/dev/e2e/session.ts | 16 +++++++++------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/ts/apps/dev/e2e/session.test.ts b/ts/apps/dev/e2e/session.test.ts index d399a0e7..cb85e017 100644 --- a/ts/apps/dev/e2e/session.test.ts +++ b/ts/apps/dev/e2e/session.test.ts @@ -18,11 +18,12 @@ const exported: Stored[] = [ expires: NOW + 60, partitionKey: { topLevelSite: 'https://x.com', hasCrossSiteAncestor: false }, }, - // snake_case, host-only, no path, a session cookie, partitioned by site alone. + // snake_case, host-only, a path, a session cookie, partitioned by site alone. { name: 'ct0', value: 'v2', domain: 'x.com', + path: '/i', http_only: false, same_site: 'Lax', expires: 0, @@ -34,8 +35,9 @@ const exported: Stored[] = [ { name: 'old', value: 'v4', domain: '.x.com', expires: NOW - 1 }, ] -test('a domain cookie keeps its domain, path and partition', () => { - assert.deepEqual(cookieParams(exported, undefined, NOW)[0], { +test('a domain cookie keeps its domain and path; of its partition, the site', () => { + const [domain] = cookieParams(exported, undefined, NOW) + assert.deepEqual(domain, { name: 'auth_token', value: 'v1', domain: '.x.com', @@ -44,24 +46,32 @@ test('a domain cookie keeps its domain, path and partition', () => { httpOnly: true, sameSite: 'None', expires: NOW + 60, + // Playwright takes the top-level site alone: hasCrossSiteAncestor (false + // in the export) is not expressible, and Playwright sends true. partitionKey: 'https://x.com', }) + assert.equal(domain && 'hasCrossSiteAncestor' in domain, false) }) -test('host-only and __Host- cookies are placed by url alone', () => { - const [, hostOnly, host] = cookieParams(exported, undefined, NOW) +test('a host-only cookie goes on its host, without a dot, with its exact path', () => { + const [, hostOnly] = cookieParams(exported, undefined, NOW) assert.deepEqual(hostOnly, { name: 'ct0', value: 'v2', - url: 'https://x.com/', + domain: 'x.com', + path: '/i', secure: true, httpOnly: false, sameSite: 'Lax', expires: -1, partitionKey: 'https://x.com', }) +}) + +test('a __Host- cookie is placed by url alone', () => { + const [, , host] = cookieParams(exported, undefined, NOW) assert.equal(host?.url, 'https://accounts.google.com/') - assert.equal(host && 'domain' in host, false) + assert.equal(host && ('domain' in host || 'path' in host), false) assert.equal(host?.expires, NOW + 60) assert.equal(host && 'partitionKey' in host, false) }) @@ -72,15 +82,14 @@ test('expired cookies are left out; each cookie goes on every domain given', () (domain) => [domain, domain.replace('x.com', 'twitter.com')], NOW, ) - assert.equal(params.length, 6) assert.ok(!params.some((param) => param.name === 'old')) assert.deepEqual( params.map((param) => param.domain ?? param.url), [ '.x.com', '.twitter.com', - 'https://x.com/', - 'https://twitter.com/', + 'x.com', + 'twitter.com', 'https://accounts.google.com/', 'https://accounts.google.com/', ], diff --git a/ts/apps/dev/e2e/session.ts b/ts/apps/dev/e2e/session.ts index 69e11057..f25bb311 100644 --- a/ts/apps/dev/e2e/session.ts +++ b/ts/apps/dev/e2e/session.ts @@ -96,9 +96,10 @@ type CookieParam = Parameters[0][number] /** * The cookies to set for a saved session, as the Rust suite sets them: a - * domain cookie on its domain, a host-only or `__Host-` one by `url` alone, - * each in its partition; an expired one is left out. `domainsOf` gives the - * domains each cookie goes on. + * domain cookie (leading dot) on its domain, a `__Host-` one by `url` alone, + * another host-only one on its host without a dot, which Chromium keeps + * host-only with its exact path; an expired one is left out. `domainsOf` + * gives the domains each cookie goes on. */ export function cookieParams( cookies: Stored[], @@ -109,13 +110,14 @@ export function cookieParams( const expires = cookie.expires ?? cookie.expirationDate ?? 0 if (expires > 0 && expires < now) return [] const key = cookie.partition_key ?? cookie.partitionKey + // Playwright takes the partition's top-level site only: an exported + // hasCrossSiteAncestor is dropped, and Playwright sets it true. const partitionKey = typeof key === 'string' ? key : (key?.topLevelSite ?? key?.top_level_site) return domainsOf(cookie.domain).map((domain): CookieParam => { const path = cookie.path || '/' - // Playwright takes a url's path up to its last slash. - const placed = domain.startsWith('.') - ? { domain, path } - : { url: `https://${domain}${path.endsWith('/') ? path : `${path}/`}` } + const placed = cookie.name.startsWith('__Host-') + ? { url: `https://${domain}/` } + : { domain, path } return { name: cookie.name, value: cookie.value, From 3cf76eb8a7498883aee73b44c479c6f63e411937 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 21:35:17 +0300 Subject: [PATCH 062/103] fix(dev): check local.ts against compose.yaml and bridge-config.toml - services.ts skips a compose port that is a range or unpublished instead of probing NaN, and refuses to start when local.ts' Bridge or notary port is not one compose.yaml publishes. - local.test.ts checks that bridge-config.toml admits APP_ORIGIN; it runs in `pnpm --filter @libid/dev test`. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/local.test.ts | 13 +++++++++++++ ts/apps/dev/local.ts | 2 +- ts/apps/dev/package.json | 2 +- ts/apps/dev/services.ts | 16 +++++++++++++--- 4 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 ts/apps/dev/local.test.ts diff --git a/ts/apps/dev/local.test.ts b/ts/apps/dev/local.test.ts new file mode 100644 index 00000000..3a9e0bd3 --- /dev/null +++ b/ts/apps/dev/local.test.ts @@ -0,0 +1,13 @@ +// local.ts against bridge-config.toml, which repeats the app's origin. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { APP_ORIGIN } from './local.ts' + +test("Bridge admits the app's origin", () => { + const config = readFileSync(new URL('./bridge-config.toml', import.meta.url), 'utf8') + const listed = /^allowed_app_origins\s*=\s*\[([^\]]*)\]/m.exec(config)?.[1] + assert.ok(listed, 'bridge-config.toml has allowed_app_origins') + const origins = [...listed.matchAll(/"([^"]*)"/g)].map((match) => match[1]) + assert.ok(origins.includes(APP_ORIGIN), `allowed_app_origins lacks ${APP_ORIGIN}`) +}) diff --git a/ts/apps/dev/local.ts b/ts/apps/dev/local.ts index c74d7836..7096fc8b 100644 --- a/ts/apps/dev/local.ts +++ b/ts/apps/dev/local.ts @@ -1,5 +1,5 @@ // The origins the app, services.ts and the tests share. compose.yaml and -// bridge-config.toml repeat the ports they need. +// bridge-config.toml repeat them; services.ts and local.test.ts check both. /** The app, served by Vite; bridge-config.toml admits this origin. */ export const APP_ORIGIN = 'http://localhost:4691' diff --git a/ts/apps/dev/package.json b/ts/apps/dev/package.json index 088f78e3..1b0ae1e2 100644 --- a/ts/apps/dev/package.json +++ b/ts/apps/dev/package.json @@ -13,7 +13,7 @@ "build:deps": "pnpm --filter '@libid/dev^...' build", "build": "vite build", "typecheck": "tsc --noEmit", - "test": "node --test e2e/settings.test.ts e2e/session.test.ts", + "test": "node --test local.test.ts e2e/settings.test.ts e2e/session.test.ts", "test:e2e": "playwright test", "test:live": "playwright test -c playwright.live.config.ts" }, diff --git a/ts/apps/dev/services.ts b/ts/apps/dev/services.ts index 1dabfaae..4f70fa9d 100644 --- a/ts/apps/dev/services.ts +++ b/ts/apps/dev/services.ts @@ -8,7 +8,7 @@ import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import { createServer as createViteServer, type ViteDevServer } from 'vite' -import { APP_ORIGIN, BRIDGE } from './local.ts' +import { APP_ORIGIN, BRIDGE, NOTARY } from './local.ts' const root = fileURLToPath(new URL('.', import.meta.url)) // Distinct Compose ownership for each checkout. No shared container names. @@ -39,14 +39,24 @@ if (!process.argv.includes('--ccdp')) { own = execFileSync('docker', [...composeArgs, 'ps', '-q'], { encoding: 'utf8' }).trim() const config = JSON.parse( execFileSync('docker', [...composeArgs, 'config', '--format', 'json'], { encoding: 'utf8' }), - ) as { services: Record } + ) as { services: Record } + // A range or an unpublished port is no single port to probe. ports = Object.values(config.services).flatMap((service) => - (service.ports ?? []).map((port) => Number(port.published)), + (service.ports ?? []).flatMap(({ published }) => + published && /^\d+$/.test(published) ? [Number(published)] : [], + ), ) } catch { console.error('Could not run Docker Compose. Install Docker with Compose and start its engine.') process.exit(1) } + for (const [name, origin] of Object.entries({ BRIDGE, NOTARY })) { + const port = Number(new URL(origin).port) + if (!ports.includes(port)) { + console.error(`local.ts's ${name} port ${port} is not one compose.yaml publishes.`) + process.exit(1) + } + } const open = await Promise.all(ports.map(listening)) const taken = ports.filter((_, index) => open[index]) if (taken.length && own) { From b5a16f63d927a64c88be976f63de8d8b8069574e Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 21:35:17 +0300 Subject: [PATCH 063/103] test(dev): list HEADED and CI as settings; read job platforms from CI - HEADED and CI join the settings table as tooling knobs, and the live config reads them through it; the README lists both, so its check covers them. - The settings check takes each job's platforms from its test step's LIBID_LIVE_PLATFORMS, the variable's default after `||` included, instead of a hard-coded list. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/README.md | 2 ++ ts/apps/dev/e2e/settings.test.ts | 22 +++++++++++++++------- ts/apps/dev/e2e/settings.ts | 9 +++++++++ ts/apps/dev/playwright.live.config.ts | 10 +++++----- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/ts/apps/dev/README.md b/ts/apps/dev/README.md index bff41723..88acc4fd 100644 --- a/ts/apps/dev/README.md +++ b/ts/apps/dev/README.md @@ -139,3 +139,5 @@ neither suite reads are reported as likely typos. | `LIBID_REQUIRE_LIVE` | tooling | optional | Set: a missing variable or an empty platform list fails instead of skipping. | | `LIBID_TEST_PROXY` | tooling | optional | A proxy for the browser's traffic, loopback aside. | | `LIBID_STACK_LOGS` | tooling | optional | A file for the services' output, containers included, instead of the terminal. | +| `HEADED` | tooling | optional | Set: show the browser. | +| `CI` | tooling | optional | Set by CI: no traces or videos, and the services' output kept off the job log. | diff --git a/ts/apps/dev/e2e/settings.test.ts b/ts/apps/dev/e2e/settings.test.ts index fd6560f7..7dc82dec 100644 --- a/ts/apps/dev/e2e/settings.test.ts +++ b/ts/apps/dev/e2e/settings.test.ts @@ -9,11 +9,8 @@ import { missing, type Need, PLATFORM_SECTIONS, VARIABLES } from './settings.ts' const read = (relative: string) => readFileSync(new URL(relative, import.meta.url), 'utf8') const workflow = read('../../../../.github/workflows/live-e2e.yml') -/** The jobs that run live tests, and the platforms each proves. */ -const JOBS: [string, PlatformId[]][] = [ - ['ceremony', ['github', 'x']], - ['google-ssm', ['google']], -] +/** The jobs that run live tests. */ +const JOBS = ['ceremony', 'google-ssm'] const isSetting = (name: string) => VARIABLES.some(([setting]) => setting === name) /** A name of the kind the settings own. */ @@ -59,8 +56,19 @@ test('every secret the workflow reads is a setting, passed under its own name', }) test("each job's test step is given what its platforms require", () => { - for (const [name, platforms] of JOBS) { - const given = new Set(testStep(name).flatMap((line) => envEntry(line)?.[0] ?? [])) + for (const name of JOBS) { + const entries = testStep(name).flatMap((line) => { + const entry = envEntry(line) + return entry ? [entry] : [] + }) + // The step's platforms: a list, or the variable's default after `||`. + const selection = entries.find(([key]) => key === 'LIBID_LIVE_PLATFORMS')?.[1] + assert.ok(selection, `job ${name}'s test step sets LIBID_LIVE_PLATFORMS`) + const listed = /\|\|\s*'([^']*)'/.exec(selection)?.[1] ?? selection + const platforms = listed.split(',').map((platform) => platform.trim()) as PlatformId[] + for (const platform of platforms) + assert.ok(platform in PLATFORM_SECTIONS, `job ${name} selects unknown ${platform}`) + const given = new Set(entries.map(([key]) => key)) const lookup = (variable: string) => (given.has(variable) ? 'set' : undefined) const absent = platforms.flatMap((platform) => PLATFORM_SECTIONS[platform].flatMap((section) => missing(section, lookup)), diff --git a/ts/apps/dev/e2e/settings.ts b/ts/apps/dev/e2e/settings.ts index f43587b2..aa736df0 100644 --- a/ts/apps/dev/e2e/settings.ts +++ b/ts/apps/dev/e2e/settings.ts @@ -35,6 +35,9 @@ export const VARIABLES: readonly (readonly [name: string, section: Section, need ['LIBID_TEST_PROXY', 'tooling', 'optional'], // Read by services.ts, in the stack's own process. ['LIBID_STACK_LOGS', 'tooling', 'optional'], + ['HEADED', 'tooling', 'optional'], + // Set by CI services, such as GitHub Actions. + ['CI', 'tooling', 'optional'], ] /** The sections each platform's live test reads; the type keeps every platform listed. */ @@ -84,6 +87,10 @@ export interface Tooling { /** A missing setting fails the run instead of skipping its test. */ requireLive: boolean proxy?: string + /** Show the browser. */ + headed: boolean + /** A CI run: no traces or videos, the stack's output kept off the job log. */ + ci: boolean } export function tooling(get: Lookup = environment): Tooling { @@ -99,6 +106,8 @@ export function tooling(get: Lookup = environment): Tooling { platforms: (names as PlatformId[] | undefined) ?? PLATFORMS, requireLive: get('LIBID_REQUIRE_LIVE') !== undefined, proxy: get('LIBID_TEST_PROXY'), + headed: get('HEADED') !== undefined, + ci: get('CI') !== undefined, } } diff --git a/ts/apps/dev/playwright.live.config.ts b/ts/apps/dev/playwright.live.config.ts index 865161bb..de1a8c17 100644 --- a/ts/apps/dev/playwright.live.config.ts +++ b/ts/apps/dev/playwright.live.config.ts @@ -13,7 +13,7 @@ if (file) { console.warn(`${file}: no live test reads ${typos.join(', ')}; check the spelling.`) } if (runner) checkSettings() -const { proxy } = tooling() +const { proxy, headed, ci } = tooling() /** Live: real platforms, real notary, real Bridge; no chain. */ export default defineConfig({ @@ -35,12 +35,12 @@ export default defineConfig({ gracefulShutdown: { signal: 'SIGTERM', timeout: 120_000 }, // CI keeps the services' output off the public job log: services.ts // writes it to LIBID_STACK_LOGS, which is scanned before upload. - stdout: process.env.CI ? 'ignore' : 'pipe', + stdout: ci ? 'ignore' : 'pipe', }, use: { ...devices['Desktop Chrome'], baseURL: APP_ORIGIN, - headless: !process.env.HEADED, + headless: !headed, // A hang ends on the action that hangs, not on the test's timeout. actionTimeout: 30_000, navigationTimeout: 60_000, @@ -51,8 +51,8 @@ export default defineConfig({ ...(proxy && { proxy: { server: proxy, bypass: 'localhost,127.0.0.1,[::1]' } }), // 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', + trace: ci ? 'off' : 'retain-on-failure', screenshot: 'only-on-failure', - video: process.env.CI ? 'off' : 'retain-on-failure', + video: ci ? 'off' : 'retain-on-failure', }, }) From ace7549351593ea885f199b149a75cee9f9a3122 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 21:35:17 +0300 Subject: [PATCH 064/103] ci(live-e2e): fit step timeouts to 15-minute tests; one stack log path - With 15-minute tests the ceremony step's worst case is 10 + 3 x 15 + 2 = 57 minutes (step 65, job 75), and Google's 10 + 15 + 2 = 27 (step 35, job 50). - LIBID_STACK_LOGS is set once, at workflow level; the failure-output action reads it instead of repeating the path. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/actions/live-e2e-output/action.yml | 9 +++++---- .github/workflows/live-e2e.yml | 23 ++++++++++++---------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/.github/actions/live-e2e-output/action.yml b/.github/actions/live-e2e-output/action.yml index e65c4f54..4093b59c 100644 --- a/.github/actions/live-e2e-output/action.yml +++ b/.github/actions/live-e2e-output/action.yml @@ -1,8 +1,9 @@ name: Keep the live end-to-end failure output description: >- - Stop any dev stack left running, scan its log for the test accounts' - secrets, then upload the PNG screenshots and the scanned log. The caller - passes the secrets to look for as env and runs this only on failure. + Stop any dev stack left running, scan its log (LIBID_STACK_LOGS) for the + test accounts' secrets, then upload the PNG screenshots and the scanned + log. The caller passes the secrets to look for as env and runs this only + on failure. # Every third-party action is pinned by commit SHA, with the tag in a # comment, so a moved tag cannot change what executes. @@ -23,7 +24,7 @@ runs: run: | docker compose ls -q --all --filter name=libid-dev | 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 + if [ -f "$LIBID_STACK_LOGS" ]; then cp "$LIBID_STACK_LOGS" "$RUNNER_TEMP/stack-scanned.log"; fi node ts/apps/dev/e2e/scan-secrets.ts "$RUNNER_TEMP/stack-scanned.log" - name: Keep the screenshots and the stack log diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 6f658f53..0700542c 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -29,6 +29,11 @@ concurrency: permissions: contents: read +env: + # Where services.ts writes the stack's output; the failure-output action + # scans and uploads it. + LIBID_STACK_LOGS: /tmp/libid-live-stack.log + jobs: ceremony: name: Live ceremonies @@ -40,7 +45,7 @@ jobs: github.actor != 'dependabot[bot]') runs-on: ubuntu-latest # The test step's budget plus setup, the secret scan and the upload. - timeout-minutes: 65 + timeout-minutes: 75 env: # A missing secret fails the run instead of skipping the test. LIBID_REQUIRE_LIVE: "1" @@ -59,15 +64,14 @@ jobs: # session arriving from a runner's address ("Verify it's you"), which no # automation passes. The google-ssm job runs it through a proxy. # Its own timeout, so a run that hangs still reaches the step below: the - # stack's 10-minute start, up to three tests of 12 minutes (all the + # stack's 10-minute start, up to three tests of 15 minutes (all the # variable can select), and 2 minutes to stop the stack - # (playwright.live.config.ts): 48 minutes, with room to spare. + # (playwright.live.config.ts): 57 minutes, with room to spare. - name: Prove identities end to end id: test - timeout-minutes: 55 + timeout-minutes: 65 env: LIBID_LIVE_PLATFORMS: ${{ vars.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 }} GH_TEST_ALICE_TOTP_SECRET: ${{ secrets.GH_TEST_ALICE_TOTP_SECRET }} @@ -112,7 +116,7 @@ jobs: github.actor != 'dependabot[bot]')) runs-on: ubuntu-latest # The test step's budget plus setup, the proxy, the secret scan and the upload. - timeout-minutes: 45 + timeout-minutes: 50 permissions: contents: read id-token: write @@ -152,15 +156,14 @@ jobs: echo "Google sees ${ip:?the proxy did not answer; see $RUNNER_TEMP/port-forward.log}" # Its own timeout, so a run that hangs still reaches the step below: the - # stack's 10-minute start, one test of 12 minutes, and 2 minutes to stop - # the stack (playwright.live.config.ts): 24 minutes, with room to spare. + # stack's 10-minute start, one test of 15 minutes, and 2 minutes to stop + # the stack (playwright.live.config.ts): 27 minutes, with room to spare. - name: Prove a Google identity end to end id: test - timeout-minutes: 30 + timeout-minutes: 35 env: LIBID_LIVE_PLATFORMS: google LIBID_TEST_PROXY: http://127.0.0.1:3128 - LIBID_STACK_LOGS: ${{ runner.temp }}/stack.log GOOGLE_TEST_ALICE_EMAIL: ${{ secrets.GOOGLE_TEST_ALICE_EMAIL }} GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} run: pnpm -C ts/apps/dev test:live From a5d31449dbb08fa4702651169da1b5cb733b36e4 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 22:01:19 +0300 Subject: [PATCH 065/103] fix(dev): keep only the platform's unexpired cookies of a saved session As the Rust suite's cookies::parse with x_host and google_host does, a saved session now keeps the cookies on x.com (or a subdomain) for X and google.com (or a subdomain) for Google, unexpired, before the "has a session" check and before restoring them. parseExport is the pure reader; the tests cover a lookalike host, a storage state and input that is not an export. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/session.test.ts | 44 +++++++++++++++++------ ts/apps/dev/e2e/session.ts | 64 +++++++++++++++++++++++---------- 2 files changed, 78 insertions(+), 30 deletions(-) diff --git a/ts/apps/dev/e2e/session.test.ts b/ts/apps/dev/e2e/session.test.ts index cb85e017..72f5d12a 100644 --- a/ts/apps/dev/e2e/session.test.ts +++ b/ts/apps/dev/e2e/session.test.ts @@ -1,7 +1,7 @@ // A synthetic `ceremony export` through the mapping restoreSession applies. import assert from 'node:assert/strict' import { test } from 'node:test' -import { cookieParams, type Stored } from './session.ts' +import { cookieParams, onGoogle, onX, parseExport, type Stored } from './session.ts' const NOW = 1_800_000_000 @@ -36,7 +36,7 @@ const exported: Stored[] = [ ] test('a domain cookie keeps its domain and path; of its partition, the site', () => { - const [domain] = cookieParams(exported, undefined, NOW) + const [domain] = cookieParams(exported) assert.deepEqual(domain, { name: 'auth_token', value: 'v1', @@ -54,7 +54,7 @@ test('a domain cookie keeps its domain and path; of its partition, the site', () }) test('a host-only cookie goes on its host, without a dot, with its exact path', () => { - const [, hostOnly] = cookieParams(exported, undefined, NOW) + const [, hostOnly] = cookieParams(exported) assert.deepEqual(hostOnly, { name: 'ct0', value: 'v2', @@ -69,20 +69,18 @@ test('a host-only cookie goes on its host, without a dot, with its exact path', }) test('a __Host- cookie is placed by url alone', () => { - const [, , host] = cookieParams(exported, undefined, NOW) + const [, , host] = cookieParams(exported) assert.equal(host?.url, 'https://accounts.google.com/') assert.equal(host && ('domain' in host || 'path' in host), false) assert.equal(host?.expires, NOW + 60) assert.equal(host && 'partitionKey' in host, false) }) -test('expired cookies are left out; each cookie goes on every domain given', () => { - const params = cookieParams( - exported, - (domain) => [domain, domain.replace('x.com', 'twitter.com')], - NOW, - ) - assert.ok(!params.some((param) => param.name === 'old')) +test('each cookie goes on every domain given', () => { + const params = cookieParams(exported.slice(0, 3), (domain) => [ + domain, + domain.replace('x.com', 'twitter.com'), + ]) assert.deepEqual( params.map((param) => param.domain ?? param.url), [ @@ -95,3 +93,27 @@ test('expired cookies are left out; each cookie goes on every domain given', () ], ) }) + +test("an export keeps the platform's unexpired cookies, as a list or under `cookies`", () => { + const list = JSON.stringify(exported) + assert.deepEqual( + parseExport(list, onX, NOW)?.map((cookie) => cookie.name), + ['auth_token', 'ct0'], + ) + assert.deepEqual( + parseExport(JSON.stringify({ cookies: exported, origins: [] }), onGoogle, NOW)?.map( + (cookie) => cookie.name, + ), + ['__Host-GAPS'], + ) + const lookalike = JSON.stringify([ + { name: 'SID', value: 'fake', domain: 'evilgoogle.com' }, + { name: 'SID', value: 'fake', domain: '.google.com' }, + ]) + assert.deepEqual( + parseExport(lookalike, onGoogle, NOW)?.map((cookie) => cookie.domain), + ['.google.com'], + ) + assert.equal(parseExport('{"cookies": 1}', onX, NOW), undefined) + assert.equal(parseExport('not json', onX, NOW), undefined) +}) diff --git a/ts/apps/dev/e2e/session.ts b/ts/apps/dev/e2e/session.ts index f25bb311..b76ec07b 100644 --- a/ts/apps/dev/e2e/session.ts +++ b/ts/apps/dev/e2e/session.ts @@ -37,29 +37,57 @@ export const exportText = (saved: SavedSession) => ? Buffer.from(saved.encoded.trim(), 'base64').toString('utf8') : readFileSync(saved.path, 'utf8') +/** A cookie's host, without the leading dot of a domain cookie. */ +const hostOf = (cookie: Stored) => cookie.domain.replace(/^\./, '') + +/** A cookie's expiry in Unix seconds; 0 for a session cookie. */ +const expiresOf = (cookie: Stored) => cookie.expires ?? cookie.expirationDate ?? 0 + +/** Hosts a platform's session cookies live on, as the Rust suite's x_host and google_host admit. */ +export const onX = (host: string) => host === 'x.com' || host.endsWith('.x.com') +export const onGoogle = (host: string) => host === 'google.com' || host.endsWith('.google.com') + /** - * The cookies of a saved session setting, or undefined when it is not set. - * `required` names cookies of which at least one must be present for the - * export to hold a session. + * The cookies of an export for the hosts `keep` admits, as the Rust suite's + * cookies::parse reads them: a list, or one under `cookies`; an expired + * cookie is left out. Undefined when the text is no such export. + */ +export function parseExport( + json: string, + keep: (host: string) => boolean, + now = Date.now() / 1000, +): Stored[] | undefined { + let cookies: unknown + try { + const parsed = JSON.parse(json) as unknown + cookies = Array.isArray(parsed) ? parsed : (parsed as { cookies?: unknown } | null)?.cookies + } catch { + // The parser's message would quote the value. + } + if (!Array.isArray(cookies)) return undefined + return (cookies as Stored[]).filter((cookie) => { + const expires = expiresOf(cookie) + return keep(hostOf(cookie)) && (expires <= 0 || expires >= now) + }) +} + +/** + * The platform's cookies of a saved session setting, or undefined when it is + * not set. `required` names cookies of which at least one must be present for + * the export to hold a session. */ function cookiesOf( saved: SavedSession | undefined, label: string, + keep: (host: string) => boolean, required: string[], ): Stored[] | undefined { if (!saved) return undefined - const json = exportText(saved) - let cookies: Stored[] | undefined - try { - const parsed = JSON.parse(json) as Stored[] | { cookies?: Stored[] } - cookies = Array.isArray(parsed) ? parsed : parsed.cookies - } catch { - // The parser's message would quote the value. - } - if (!Array.isArray(cookies)) + const cookies = parseExport(exportText(saved), keep) + if (!cookies) throw new Error(`The saved ${label} session is not a JSON cookie list or { cookies }`) if (!cookies.some((cookie) => required.includes(cookie.name))) - throw new Error(`The saved ${label} session has none of ${required.join(', ')}`) + throw new Error(`The saved ${label} session has none of ${required.join(', ')} unexpired`) return cookies } @@ -69,7 +97,7 @@ function cookiesOf( * ceremony-tests, which lives on its feat/live-ceremony-tests branch * (libid-server-rs PR #12). */ -export const xSession = () => cookiesOf(settings.xSession(), 'X_TEST_ALICE', ['auth_token']) +export const xSession = () => cookiesOf(settings.xSession(), 'X_TEST_ALICE', onX, ['auth_token']) /** * The saved Google session, from GOOGLE_TEST_ALICE_COOKIES or @@ -79,7 +107,7 @@ export const xSession = () => cookiesOf(settings.xSession(), 'X_TEST_ALICE', ['a * sign-in in an automated browser, but honours a session made elsewhere. */ export const googleSession = () => - cookiesOf(settings.googleSession(), 'GOOGLE_TEST_ALICE', [ + cookiesOf(settings.googleSession(), 'GOOGLE_TEST_ALICE', onGoogle, [ 'SID', '__Secure-1PSID', '__Secure-3PSID', @@ -98,17 +126,15 @@ type CookieParam = Parameters[0][number] * The cookies to set for a saved session, as the Rust suite sets them: a * domain cookie (leading dot) on its domain, a `__Host-` one by `url` alone, * another host-only one on its host without a dot, which Chromium keeps - * host-only with its exact path; an expired one is left out. `domainsOf` + * host-only with its exact path. `domainsOf` * gives the domains each cookie goes on. */ export function cookieParams( cookies: Stored[], domainsOf: (domain: string) => string[] = (domain) => [domain], - now = Date.now() / 1000, ): CookieParam[] { return cookies.flatMap((cookie) => { - const expires = cookie.expires ?? cookie.expirationDate ?? 0 - if (expires > 0 && expires < now) return [] + const expires = expiresOf(cookie) const key = cookie.partition_key ?? cookie.partitionKey // Playwright takes the partition's top-level site only: an exported // hasCrossSiteAncestor is dropped, and Playwright sets it true. From b8ce6dfa7519344c255292b504b3fd38a678bd38 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 22:01:19 +0300 Subject: [PATCH 066/103] fix(dev): resolve cookie files like the env file; strict switches - A relative *_COOKIES_FILE resolves next to the env file, or without one against INIT_CWD (where pnpm was invoked), else the working directory: the rule LIBID_TEST_ENV_FILE follows. The secret scan reads sessions through the same resolver. - CI, HEADED and LIBID_REQUIRE_LIVE are on only for `1` or `true`, in any case; `0`, `false` or another value is off. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/settings.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/ts/apps/dev/e2e/settings.ts b/ts/apps/dev/e2e/settings.ts index aa736df0..bdbe9102 100644 --- a/ts/apps/dev/e2e/settings.ts +++ b/ts/apps/dev/e2e/settings.ts @@ -3,7 +3,7 @@ // are those of libid-server-rs' ceremony-tests (src/settings.rs), which reads // the same .env.test. An empty value is an absent one. import { readFileSync } from 'node:fs' -import { dirname, isAbsolute, join, resolve } from 'node:path' +import { dirname, resolve } from 'node:path' import { parseEnv } from 'node:util' import type { PlatformId } from '@libid/ceremony' @@ -64,10 +64,13 @@ export type Lookup = (name: string) => string | undefined const environment: Lookup = (name) => process.env[name] || undefined +/** Where pnpm was invoked: what a relative path in a setting is relative to. */ +const invoked = (get: Lookup) => get('INIT_CWD') ?? process.cwd() + /** The settings file LIBID_TEST_ENV_FILE names, relative to where pnpm was invoked. */ export function envFile(get: Lookup = environment): string | undefined { const file = get('LIBID_TEST_ENV_FILE') - return file && resolve(get('INIT_CWD') ?? process.cwd(), file) + return file && resolve(invoked(get), file) } /** @@ -93,6 +96,9 @@ export interface Tooling { ci: boolean } +/** A switch: on for `1` or `true`, in any case; off for anything else. */ +const flag = (get: Lookup, name: string) => /^(1|true)$/i.test(get(name) ?? '') + export function tooling(get: Lookup = environment): Tooling { const listed = get('LIBID_LIVE_PLATFORMS') const names = listed @@ -104,10 +110,10 @@ export function tooling(get: Lookup = environment): Tooling { throw new Error(`LIBID_LIVE_PLATFORMS names ${unknown.join(', ')}; use ${PLATFORMS.join(', ')}`) return { platforms: (names as PlatformId[] | undefined) ?? PLATFORMS, - requireLive: get('LIBID_REQUIRE_LIVE') !== undefined, + requireLive: flag(get, 'LIBID_REQUIRE_LIVE'), proxy: get('LIBID_TEST_PROXY'), - headed: get('HEADED') !== undefined, - ci: get('CI') !== undefined, + headed: flag(get, 'HEADED'), + ci: flag(get, 'CI'), } } @@ -195,9 +201,9 @@ function savedSession(prefix: string, get: Lookup): SavedSession | undefined { if (encoded) return { encoded } const file = get(`${prefix}_COOKIES_FILE`) if (!file) return undefined - // A relative path is the settings file's neighbour. - const directory = envFile(get) - return { path: isAbsolute(file) || !directory ? file : join(dirname(directory), file) } + // A relative path is the settings file's neighbour, else relative to where pnpm was invoked. + const settingsFile = envFile(get) + return { path: resolve(settingsFile ? dirname(settingsFile) : invoked(get), file) } } export const xSession = (get: Lookup = environment) => savedSession('X_TEST_ALICE', get) From e37e8fac86ceca8277c855fd667b78082360b80e Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 22:01:19 +0300 Subject: [PATCH 067/103] fix(dev): scan for encoded secrets and storage-state localStorage values The scan also looks for each value URL-encoded and JSON-escaped, and collects origins[].localStorage values from a Playwright storage state export, beside its cookies. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/scan-secrets.ts | 52 +++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/ts/apps/dev/e2e/scan-secrets.ts b/ts/apps/dev/e2e/scan-secrets.ts index a89e602e..c48f9119 100644 --- a/ts/apps/dev/e2e/scan-secrets.ts +++ b/ts/apps/dev/e2e/scan-secrets.ts @@ -1,13 +1,19 @@ // Refuse to publish a failed run's stack log when it holds a test secret: the -// GitHub password or TOTP secret, a saved session as set, or a value of its -// cookies. A session that does not decode is still looked for as set. Paths -// are the arguments; a missing file is skipped. Nothing secret is printed. +// GitHub password or TOTP secret, a saved session as set, or a value it holds +// (its cookies' and, for a storage state, its origins' localStorage values), +// each also URL-encoded and JSON-escaped. A session that does not decode is +// still looked for as set. Paths are the arguments; a missing file is +// skipped. Nothing secret is printed. import { existsSync, readFileSync } from 'node:fs' import { exportText } from './session.ts' import { githubSecrets, googleSession, type SavedSession, xSession } from './settings.ts' +/** The `value` strings of `items`, where it is a list. */ +const values = (items: unknown) => + Array.isArray(items) ? items.map((item) => (item as { value?: unknown } | null)?.value) : [] + /** - * The session setting's own value, and its cookies' values: from the JSON + * The session setting's own value, and the values it holds: from the JSON * where it parses, else every `"value": "…"` string its text holds. */ function sessionSecrets(saved: SavedSession | undefined): string[] { @@ -19,24 +25,38 @@ function sessionSecrets(saved: SavedSession | undefined): string[] { } catch { return raw } + let held: unknown[] try { - const parsed: unknown = JSON.parse(text) - const cookies = Array.isArray(parsed) ? parsed : (parsed as { cookies?: unknown })?.cookies - if (Array.isArray(cookies)) - return [...raw, ...cookies.map((cookie) => (cookie as { value?: unknown })?.value)].filter( - (value): value is string => typeof value === 'string', - ) + const parsed = JSON.parse(text) as unknown + const state = parsed as { cookies?: unknown; origins?: unknown } | null + held = Array.isArray(parsed) + ? values(parsed) + : [ + ...values(state?.cookies), + ...(Array.isArray(state?.origins) ? state.origins : []).flatMap((origin) => + values((origin as { localStorage?: unknown } | null)?.localStorage), + ), + ] } catch { - // Not JSON: fall back to the value strings in the text. + held = [...text.matchAll(/"value"\s*:\s*"([^"\\]*)"/g)].map((match) => match[1]) } - return [...raw, ...[...text.matchAll(/"value"\s*:\s*"([^"\\]*)"/g)].map((match) => match[1]!)] + return [...raw, ...held.filter((value): value is string => typeof value === 'string')] } +/** A secret as written, URL-encoded and JSON-escaped. */ +const forms = (secret: string) => [ + secret, + encodeURIComponent(secret), + JSON.stringify(secret).slice(1, -1), +] + const secrets = [ - ...githubSecrets(), - ...sessionSecrets(xSession()), - ...sessionSecrets(googleSession()), -].filter((secret) => secret.length >= 8) + ...new Set( + [...githubSecrets(), ...sessionSecrets(xSession()), ...sessionSecrets(googleSession())] + .filter((secret) => secret.length >= 8) + .flatMap(forms), + ), +] const leaking = process.argv .slice(2) From 1d68425f5c7a5082dc1fa47e1087e4ed12d8649b Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 22:01:19 +0300 Subject: [PATCH 068/103] fix(dev): check that local.ts' notary URL reaches the notary Bridge readiness already proves BRIDGE serves the Bridge's route; once it holds, services.ts asks NOTARY for the notary image's /healthcheck and stops with a named error when it does not answer 200. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/services.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ts/apps/dev/services.ts b/ts/apps/dev/services.ts index 4f70fa9d..948ef7bb 100644 --- a/ts/apps/dev/services.ts +++ b/ts/apps/dev/services.ts @@ -50,6 +50,7 @@ if (!process.argv.includes('--ccdp')) { console.error('Could not run Docker Compose. Install Docker with Compose and start its engine.') process.exit(1) } + // Which service answers on each is checked once the stack is up. for (const [name, origin] of Object.entries({ BRIDGE, NOTARY })) { const port = Number(new URL(origin).port) if (!ports.includes(port)) { @@ -148,6 +149,26 @@ if (process.argv.includes('--app')) { } await delay(500) } + // Readiness above proves BRIDGE is the Bridge; only the notary serves /healthcheck. + if (!stopping) { + const notary = await fetch( + new URL('/healthcheck', NOTARY.replace('localhost', '127.0.0.1')), + { + signal: AbortSignal.timeout(2000), + }, + ) + .then(async (response) => { + await response.body?.cancel() + return response.status + }) + .catch(() => 0) + if (notary !== 200) { + console.error( + `local.ts's NOTARY ${NOTARY} does not answer as the notary (GET /healthcheck).`, + ) + stop(1) + } + } if (!stopping) { frontend = await createViteServer({ configFile: join(root, 'vite.config.ts') }) if (stopping) await frontend.close() From aeecb8be7ba1803590bac257de0ed05627ce28e3 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 22:01:19 +0300 Subject: [PATCH 069/103] fix(dev): cancel evaluateWithin's timer once the evaluation settles Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/popup.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ts/apps/dev/e2e/popup.ts b/ts/apps/dev/e2e/popup.ts index 02d1b09e..c1edadfd 100644 --- a/ts/apps/dev/e2e/popup.ts +++ b/ts/apps/dev/e2e/popup.ts @@ -100,7 +100,11 @@ export async function drivePopup(popup: Page, driver: Driver) { /** Evaluate `expression` in `popup`, or `fallback` when it fails or takes over 5 s. */ export function evaluateWithin(popup: Page, expression: string, fallback: T): Promise { - return Promise.race([popup.evaluate(expression).catch(() => fallback), sleep(5_000, fallback)]) + const timer = new AbortController() + return Promise.race([ + popup.evaluate(expression).catch(() => fallback), + sleep(5_000, fallback, { signal: timer.signal }).catch(() => fallback), + ]).finally(() => timer.abort()) } /** From 406aeafbba7e979346519fc847833757011d2d74 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 22:01:19 +0300 Subject: [PATCH 070/103] test(dev): one budgets module; check CI timeouts and coverage against it - e2e/budgets.ts holds the live run's budgets (stack start and stop, a page load, an action, Ready, the driver, the outcome, one test); the config, the spec and the popup driver take them from there. - settings.test.ts checks that a test's timeout covers its budgets, and that each job's test step allows the stack plus one test per platform it can select. - A step whose LIBID_LIVE_PLATFORMS the repository variable can choose must be given every platform's settings; one with a literal list, its own. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/budgets.ts | 21 +++++++++ ts/apps/dev/e2e/ceremony.live.spec.ts | 8 ++-- ts/apps/dev/e2e/popup.ts | 3 +- ts/apps/dev/e2e/settings.test.ts | 63 +++++++++++++++++++++------ ts/apps/dev/playwright.live.config.ts | 15 +++---- 5 files changed, 83 insertions(+), 27 deletions(-) create mode 100644 ts/apps/dev/e2e/budgets.ts diff --git a/ts/apps/dev/e2e/budgets.ts b/ts/apps/dev/e2e/budgets.ts new file mode 100644 index 00000000..da5096b4 --- /dev/null +++ b/ts/apps/dev/e2e/budgets.ts @@ -0,0 +1,21 @@ +// The live run's time budgets, in milliseconds: the config, the spec and the +// popup driver take them from here, and settings.test.ts checks the CI step +// timeouts against them. + +/** Starting the stack and the app (webServer). */ +export const STACK_START = 10 * 60_000 +/** Stopping them (webServer's graceful shutdown). */ +export const STACK_STOP = 2 * 60_000 +/** A page load. */ +export const NAVIGATION = 60_000 +/** One action, such as the click that opens the popup. */ +export const ACTION = 30_000 +/** The app's cold start to Ready. */ +export const READY = 60_000 +/** The popup driver, sign-in to the platform's return. */ +export const DRIVER = 180_000 +/** Proving in the browser, after the platform returns the popup. */ +export const OUTCOME = 8 * 60_000 + +/** One test: the budgets above, 13.5 minutes, with margin. */ +export const TEST = 15 * 60_000 diff --git a/ts/apps/dev/e2e/ceremony.live.spec.ts b/ts/apps/dev/e2e/ceremony.live.spec.ts index 858f448b..0aeb2ef4 100644 --- a/ts/apps/dev/e2e/ceremony.live.spec.ts +++ b/ts/apps/dev/e2e/ceremony.live.spec.ts @@ -1,5 +1,6 @@ import type { PlatformId } from '@libid/ceremony' import { type BrowserContext, expect, type Page, test } from '@playwright/test' +import { OUTCOME, READY } from './budgets.ts' import { authorizeOnGitHub } from './github.ts' import { authorizeOnGoogle } from './google.ts' import { presentAsPerson } from './person.ts' @@ -15,9 +16,6 @@ import { } from './settings.ts' import { authorizeOnX, restoreXSession } from './x.ts' -/** Proving in the browser, after the platform returns the popup. */ -const OUTCOME_TIMEOUT = 8 * 60_000 - type Name = 'GitHub' | 'X' | 'Google' const platformOf: Record = { GitHub: 'github', X: 'x', Google: 'google' } @@ -59,7 +57,7 @@ async function proveThroughTheUi( await page.goto('/') // A cold app loads the Bridge configuration and CCDP's version list first. await expect(page.locator('#status')).toHaveText('Ready. Click a platform to start a ceremony.', { - timeout: 60_000, + timeout: READY, }) const [popup] = await Promise.all([ // Watched from the moment it appears: a platform may return it at once. @@ -71,7 +69,7 @@ async function proveThroughTheUi( await authorize(popup) await expect - .poll(() => page.evaluate((id) => window.results.has(id), id!), { timeout: OUTCOME_TIMEOUT }) + .poll(() => page.evaluate((id) => window.results.has(id), id!), { timeout: OUTCOME }) .toBe(true) const outcome = await page.evaluate((id): Outcome => { const result = window.results.get(id)! diff --git a/ts/apps/dev/e2e/popup.ts b/ts/apps/dev/e2e/popup.ts index c1edadfd..eca65c32 100644 --- a/ts/apps/dev/e2e/popup.ts +++ b/ts/apps/dev/e2e/popup.ts @@ -1,8 +1,9 @@ import { setTimeout as sleep } from 'node:timers/promises' import type { Page, Request } from '@playwright/test' import { BRIDGE } from '../local.ts' +import { DRIVER } from './budgets.ts' -const BUDGET_MS = 180_000 +const BUDGET_MS = DRIVER /** Where every platform returns the popup: the local Bridge's OAuth callback. */ const CALLBACK = new URL('/auth/callback', BRIDGE) diff --git a/ts/apps/dev/e2e/settings.test.ts b/ts/apps/dev/e2e/settings.test.ts index 7dc82dec..49aba930 100644 --- a/ts/apps/dev/e2e/settings.test.ts +++ b/ts/apps/dev/e2e/settings.test.ts @@ -4,6 +4,16 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { test } from 'node:test' import type { PlatformId } from '@libid/ceremony' +import { + ACTION, + DRIVER, + NAVIGATION, + OUTCOME, + READY, + STACK_START, + STACK_STOP, + TEST, +} from './budgets.ts' import { missing, type Need, PLATFORM_SECTIONS, VARIABLES } from './settings.ts' const read = (relative: string) => readFileSync(new URL(relative, import.meta.url), 'utf8') @@ -55,28 +65,55 @@ test('every secret the workflow reads is a setting, passed under its own name', } }) -test("each job's test step is given what its platforms require", () => { +/** + * The platforms job `name`'s test step can prove: every one when the + * repository variable may choose, else the ones it lists. + */ +function selectable(entries: [string, string][], name: string): PlatformId[] { + const selection = entries.find(([key]) => key === 'LIBID_LIVE_PLATFORMS')?.[1] + assert.ok(selection, `job ${name}'s test step sets LIBID_LIVE_PLATFORMS`) + if (referenced(selection).size) return Object.keys(PLATFORM_SECTIONS) as PlatformId[] + const platforms = selection.split(',').map((platform) => platform.trim()) as PlatformId[] + for (const platform of platforms) + assert.ok(platform in PLATFORM_SECTIONS, `job ${name} selects unknown ${platform}`) + return platforms +} + +const entriesOf = (lines: string[]) => + lines.flatMap((line) => { + const entry = envEntry(line) + return entry ? [entry] : [] + }) + +test("each job's test step is given what every platform it can select requires", () => { for (const name of JOBS) { - const entries = testStep(name).flatMap((line) => { - const entry = envEntry(line) - return entry ? [entry] : [] - }) - // The step's platforms: a list, or the variable's default after `||`. - const selection = entries.find(([key]) => key === 'LIBID_LIVE_PLATFORMS')?.[1] - assert.ok(selection, `job ${name}'s test step sets LIBID_LIVE_PLATFORMS`) - const listed = /\|\|\s*'([^']*)'/.exec(selection)?.[1] ?? selection - const platforms = listed.split(',').map((platform) => platform.trim()) as PlatformId[] - for (const platform of platforms) - assert.ok(platform in PLATFORM_SECTIONS, `job ${name} selects unknown ${platform}`) + const entries = entriesOf(testStep(name)) const given = new Set(entries.map(([key]) => key)) const lookup = (variable: string) => (given.has(variable) ? 'set' : undefined) - const absent = platforms.flatMap((platform) => + const absent = selectable(entries, name).flatMap((platform) => PLATFORM_SECTIONS[platform].flatMap((section) => missing(section, lookup)), ) assert.deepEqual(absent, [], `job ${name}'s test step is not given ${absent.join(', ')}`) } }) +test('each test step outlasts its worst case: the stack, every platform it can select', () => { + const minutes = (ms: number) => ms / 60_000 + const sum = NAVIGATION + READY + ACTION + DRIVER + OUTCOME + assert.ok( + TEST >= sum, + `the test timeout ${minutes(TEST)} min is under its budgets' ${minutes(sum)}`, + ) + for (const name of JOBS) { + const step = testStep(name) + const timeout = Number(/^\s+timeout-minutes:\s*(\d+)/m.exec(step.join('\n'))?.[1]) + const worst = minutes( + STACK_START + selectable(entriesOf(step), name).length * TEST + STACK_STOP, + ) + assert.ok(timeout >= worst, `job ${name}'s test step allows ${timeout} min of ${worst}`) + } +}) + test("the README's live tests table lists every setting as the table does", () => { const rows = read('../README.md') .split('\n') diff --git a/ts/apps/dev/playwright.live.config.ts b/ts/apps/dev/playwright.live.config.ts index de1a8c17..550d8b15 100644 --- a/ts/apps/dev/playwright.live.config.ts +++ b/ts/apps/dev/playwright.live.config.ts @@ -1,4 +1,5 @@ import { defineConfig, devices } from '@playwright/test' +import { ACTION, NAVIGATION, STACK_START, STACK_STOP, TEST } from './e2e/budgets.ts' import { checkSettings, envFile, loadEnvFile, tooling, unknownKeys } from './e2e/settings.ts' import { APP_ORIGIN } from './local.ts' @@ -20,10 +21,8 @@ export default defineConfig({ testDir: 'e2e', testMatch: '*.live.spec.ts', workers: 1, - // Each test's own budgets: the page load (60 s), the app's Ready (60 s), - // the click that opens the popup (30 s), the popup driver (180 s) and the - // outcome wait (8 min): 13.5 minutes, with margin. - timeout: 15 * 60_000, + // Each test's own budgets, summed in e2e/budgets.ts. + timeout: TEST, reporter: [['list']], // One command: start the services and the app, test, then stop both. The // run never reuses a running stack: services.ts refuses taken ports. @@ -31,8 +30,8 @@ export default defineConfig({ command: 'pnpm dev', url: APP_ORIGIN, reuseExistingServer: false, - timeout: 10 * 60_000, - gracefulShutdown: { signal: 'SIGTERM', timeout: 120_000 }, + timeout: STACK_START, + gracefulShutdown: { signal: 'SIGTERM', timeout: STACK_STOP }, // CI keeps the services' output off the public job log: services.ts // writes it to LIBID_STACK_LOGS, which is scanned before upload. stdout: ci ? 'ignore' : 'pipe', @@ -42,8 +41,8 @@ export default defineConfig({ baseURL: APP_ORIGIN, headless: !headed, // A hang ends on the action that hangs, not on the test's timeout. - actionTimeout: 30_000, - navigationTimeout: 60_000, + actionTimeout: ACTION, + navigationTimeout: NAVIGATION, // Automation X and Google can see makes them challenge; see e2e/person.ts. launchOptions: { args: ['--disable-blink-features=AutomationControlled'] }, // LIBID_TEST_PROXY routes the browser's traffic, loopback aside, through a From 745fb1346c49277a74bf174b0688c733a785444a Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 22:01:19 +0300 Subject: [PATCH 071/103] docs(dev): point to local.ts for the origins and check the README's The Configure list says local.ts holds the app origin and the Bridge and notary URLs; local.test.ts checks the README's service table, its app link and its callback URL against them. The live tests section states the switch values and the cookie file path rule. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/README.md | 14 ++++++++------ ts/apps/dev/local.test.ts | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/ts/apps/dev/README.md b/ts/apps/dev/README.md index 88acc4fd..54009c36 100644 --- a/ts/apps/dev/README.md +++ b/ts/apps/dev/README.md @@ -37,8 +37,8 @@ Edit configuration directly where it is used: - [compose.yaml](compose.yaml): services. - [bridge-config.toml](bridge-config.toml): Bridge configuration and development OAuth credentials. -- [local.ts](local.ts): the app origin (frontend port) and Bridge URL. -- [src/app.ts](src/app.ts): the local ledger/notary fixture; Bridge configuration selects CCDP. +- [local.ts](local.ts): the app origin (frontend port), and the Bridge and notary URLs; the table above repeats them, and `pnpm --filter @libid/dev test` checks it. +- [src/app.ts](src/app.ts): the local ledger fixture; Bridge configuration selects CCDP. The setup includes shared development OAuth credentials and a public development notary signing key; no environment file is needed. Bridge publishes the GitHub @@ -120,7 +120,9 @@ The variables, read through [e2e/settings.ts](e2e/settings.ts), carry the names libid-server-rs' ceremony-tests uses, so one `.env.test` serves both suites. With `LIBID_REQUIRE_LIVE`, the run checks every selected platform's variables at once and names each one absent; without it, a test lacking its -variables is skipped. Keys in the env file that look like settings but that +variables is skipped. A switch is on for `1` or `true`, in any case. A relative +`*_COOKIES_FILE` path is relative to the env file, or, without one, to where +you run pnpm. Keys in the env file that look like settings but that neither suite reads are reported as likely typos. | Variable | Section | Need | Use | @@ -136,8 +138,8 @@ neither suite reads are reported as likely typos. | `GOOGLE_TEST_ALICE_COOKIES_FILE` | google-session | instead of `GOOGLE_TEST_ALICE_COOKIES` | A path to that JSON, as for X. | | `LIBID_TEST_ENV_FILE` | tooling | optional | A dotenv file with the variables above, relative to where you run pnpm; exported values win. | | `LIBID_LIVE_PLATFORMS` | tooling | optional | Comma-separated platforms to prove, such as `github,x`; all three by default. | -| `LIBID_REQUIRE_LIVE` | tooling | optional | Set: a missing variable or an empty platform list fails instead of skipping. | +| `LIBID_REQUIRE_LIVE` | tooling | optional | `1` or `true`: a missing variable or an empty platform list fails instead of skipping. | | `LIBID_TEST_PROXY` | tooling | optional | A proxy for the browser's traffic, loopback aside. | | `LIBID_STACK_LOGS` | tooling | optional | A file for the services' output, containers included, instead of the terminal. | -| `HEADED` | tooling | optional | Set: show the browser. | -| `CI` | tooling | optional | Set by CI: no traces or videos, and the services' output kept off the job log. | +| `HEADED` | tooling | optional | `1` or `true`: show the browser. | +| `CI` | tooling | optional | `1` or `true`, as CI services set it: no traces or videos, and the services' output kept off the job log. | diff --git a/ts/apps/dev/local.test.ts b/ts/apps/dev/local.test.ts index 3a9e0bd3..40e3eda5 100644 --- a/ts/apps/dev/local.test.ts +++ b/ts/apps/dev/local.test.ts @@ -1,8 +1,8 @@ -// local.ts against bridge-config.toml, which repeats the app's origin. +// local.ts against the files that repeat its origins. import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { test } from 'node:test' -import { APP_ORIGIN } from './local.ts' +import { APP_ORIGIN, BRIDGE, NOTARY } from './local.ts' test("Bridge admits the app's origin", () => { const config = readFileSync(new URL('./bridge-config.toml', import.meta.url), 'utf8') @@ -11,3 +11,15 @@ test("Bridge admits the app's origin", () => { const origins = [...listed.matchAll(/"([^"]*)"/g)].map((match) => match[1]) assert.ok(origins.includes(APP_ORIGIN), `allowed_app_origins lacks ${APP_ORIGIN}`) }) + +test("the README's service table and links name local.ts' origins", () => { + const readme = readFileSync(new URL('./README.md', import.meta.url), 'utf8') + for (const line of [ + `| Application | ${APP_ORIGIN} |`, + `| OAuth Bridge | ${BRIDGE} |`, + `| Notary HTTP/WebSocket | ${NOTARY} |`, + `Open **${APP_ORIGIN}**`, + `**\`${BRIDGE}/auth/callback\`**`, + ]) + assert.ok(readme.includes(line), `the README says ${line}`) +}) From e44eaa23e58dc83d41c501e355046e6d64ad87b6 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 22:26:36 +0300 Subject: [PATCH 072/103] fix(dev): stop after two refused TOTP codes; test the TOTP vector The TOTP step submits at most two codes a run, one per 30-second step: the code form showing again in a later step after the second fails the run, naming GH_TEST_ALICE_TOTP_SECRET and the host's clock, and GitHub's "two-factor authentication failed" when the page says so. github.test.ts checks totp() against the reference vector libid-server-rs' github.rs checks (JBSWY3DPEHPK3PXP at 1_700_000_000 is 324550), spaced and lower-case too, and that a key that is not base32 names its setting. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/github.test.ts | 19 +++++++++++++++++++ ts/apps/dev/e2e/github.ts | 23 +++++++++++++++++++---- 2 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 ts/apps/dev/e2e/github.test.ts diff --git a/ts/apps/dev/e2e/github.test.ts b/ts/apps/dev/e2e/github.test.ts new file mode 100644 index 00000000..e6872860 --- /dev/null +++ b/ts/apps/dev/e2e/github.test.ts @@ -0,0 +1,19 @@ +// The TOTP code against the reference vector the Rust suite checks (github.rs). +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { totp } from './github.ts' + +test('the TOTP code matches the reference vector, spaces and case aside', () => { + assert.equal(totp('JBSWY3DPEHPK3PXP', 1_700_000_000_000), '324550') + assert.equal(totp('jbsw y3dp ehpk 3pxp', 1_700_000_000_000), '324550') +}) + +test('a key that is not base32 names its setting, not its value', () => { + assert.throws( + () => totp('not-base32!'), + (error: Error) => { + assert.equal(error.message, 'GH_TEST_ALICE_TOTP_SECRET is not a base32 key') + return true + }, + ) +}) diff --git a/ts/apps/dev/e2e/github.ts b/ts/apps/dev/e2e/github.ts index 10080e08..594897d7 100644 --- a/ts/apps/dev/e2e/github.ts +++ b/ts/apps/dev/e2e/github.ts @@ -8,14 +8,20 @@ const STEP_TIMEOUT = 5_000 /** How long a submitted login form may still show before it counts as refused. */ const LOGIN_SETTLE = 15_000 -function totp(secret: string): string { +/** Codes the TOTP step submits before a refusal fails the run. */ +const TOTP_ATTEMPTS = 2 + +/** The TOTP code for the base32 `secret`, spaces and case aside, at `time` (ms). */ +export function totp(secret: string, time = Date.now()): string { let key: OTPAuth.Secret try { key = OTPAuth.Secret.fromBase32(secret.replace(/\s/g, '').toUpperCase()) } catch { throw new Error('GH_TEST_ALICE_TOTP_SECRET is not a base32 key') } - return new OTPAuth.TOTP({ secret: key, algorithm: 'SHA1', digits: 6, period: 30 }).generate() + return new OTPAuth.TOTP({ secret: key, algorithm: 'SHA1', digits: 6, period: 30 }).generate({ + timestamp: time, + }) } /** What the login has done so far; each handler reads and updates it. */ @@ -24,7 +30,9 @@ interface Progress { start?: string /** When the login form was last submitted. */ filledAt?: number + /** The 30-second step whose code was last submitted, and how many were. */ totpStep?: number + totpAttempts: number /** When the authorize button was last clicked; it is not clicked again within 5 s. */ authorizedAt: number /** When the 2FA checkup was last skipped; it is not skipped again within 5 s. */ @@ -94,9 +102,14 @@ async function onLogin( progress.filledAt = Date.now() } -async function onTotp(popup: Page, account: GitHubAccount, progress: Progress) { +async function onTotp(popup: Page, account: GitHubAccount, progress: Progress, text: string) { const step = Math.floor(Date.now() / 30_000) if (progress.totpStep === step || !(await popup.locator('#app_totp').count())) return + // The code form again, in a later step, after a submitted code: GitHub refused it. + if (progress.totpAttempts >= TOTP_ATTEMPTS) + throw new Error( + `GitHub refused ${TOTP_ATTEMPTS} TOTP codes${text.includes('two-factor authentication failed') ? ' (two-factor authentication failed)' : ''}: check GH_TEST_ALICE_TOTP_SECRET and this host's clock`, + ) const code = totp(account.totpSecret) try { await popup.fill('#app_totp', code, { timeout: STEP_TIMEOUT }) @@ -109,6 +122,7 @@ async function onTotp(popup: Page, account: GitHubAccount, progress: Progress) { .click({ timeout: 2_000 }) .catch(() => {}) progress.totpStep = step + progress.totpAttempts++ } async function onAuthorize(popup: Page, progress: Progress, text: string) { @@ -154,7 +168,7 @@ async function onGitHubPage( return onTwoFactorCheckup(popup, progress) 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 === '/sessions/two-factor/app') return onTotp(popup, account, progress, text) if (path.startsWith('/sessions/two-factor')) { await popup .goto('https://github.com/sessions/two-factor/app', { timeout: STEP_TIMEOUT * 2 }) @@ -177,6 +191,7 @@ export async function authorizeOnGitHub(popup: Page, account: GitHubAccount) { const progress: Progress = { authorizedAt: 0, skippedCheckupAt: 0, + totpAttempts: 0, rateLimited: 0, errored: 0, } From a5b747e90a1dcd948aee0bd10106f00daa476099 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 22:26:36 +0300 Subject: [PATCH 073/103] fix(dev): read export entries as cookies.rs does, never a TypeError An export entry is a cookie only as an object with string name, value and domain; the capitalised spellings cookies.rs' serde aliases accept (Name, Value, Domain, Path, Secure) read the same. Any other entry makes the export unreadable, which the saved-session readers report as "not a JSON cookie list". Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/session.test.ts | 17 +++++++++++++++++ ts/apps/dev/e2e/session.ts | 30 +++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/ts/apps/dev/e2e/session.test.ts b/ts/apps/dev/e2e/session.test.ts index 72f5d12a..8129899d 100644 --- a/ts/apps/dev/e2e/session.test.ts +++ b/ts/apps/dev/e2e/session.test.ts @@ -117,3 +117,20 @@ test("an export keeps the platform's unexpired cookies, as a list or under `cook assert.equal(parseExport('{"cookies": 1}', onX, NOW), undefined) assert.equal(parseExport('not json', onX, NOW), undefined) }) + +test('an export entry is a cookie only with string name, value and domain', () => { + const capitalised = JSON.stringify([ + { Name: 'auth_token', Value: 'fake', Domain: '.x.com', Path: '/', Secure: true }, + ]) + const [cookie] = parseExport(capitalised, onX, NOW) ?? [] + assert.equal(cookie?.name, 'auth_token') + assert.equal(cookie?.domain, '.x.com') + assert.equal(cookie?.path, '/') + for (const bad of [ + '[null]', + '["auth_token"]', + '[{"name":"auth_token","value":"fake"}]', + '[{"name":"auth_token","value":1,"domain":".x.com"}]', + ]) + assert.equal(parseExport(bad, onX, NOW), undefined, bad) +}) diff --git a/ts/apps/dev/e2e/session.ts b/ts/apps/dev/e2e/session.ts index b76ec07b..d15937ab 100644 --- a/ts/apps/dev/e2e/session.ts +++ b/ts/apps/dev/e2e/session.ts @@ -47,10 +47,32 @@ const expiresOf = (cookie: Stored) => cookie.expires ?? cookie.expirationDate ?? export const onX = (host: string) => host === 'x.com' || host.endsWith('.x.com') export const onGoogle = (host: string) => host === 'google.com' || host.endsWith('.google.com') +/** Capitalised spellings an export may use, as cookies.rs' serde aliases accept. */ +const CAPITALISED = { + Name: 'name', + Value: 'value', + Domain: 'domain', + Path: 'path', + Secure: 'secure', +} + +/** One export entry as a cookie: an object with string name, value and domain; else undefined. */ +function storedOf(entry: unknown): Stored | undefined { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return undefined + const cookie: Record = { ...entry } + for (const [spelled, name] of Object.entries(CAPITALISED)) + if (spelled in cookie && !(name in cookie)) cookie[name] = cookie[spelled] + const { name, value, domain } = cookie + return typeof name === 'string' && typeof value === 'string' && typeof domain === 'string' + ? (cookie as unknown as Stored) + : undefined +} + /** * The cookies of an export for the hosts `keep` admits, as the Rust suite's - * cookies::parse reads them: a list, or one under `cookies`; an expired - * cookie is left out. Undefined when the text is no such export. + * cookies::parse reads them: a list, or one under `cookies`, of entries with + * string name, value and domain; an expired cookie is left out. Undefined + * when the text is no such export. */ export function parseExport( json: string, @@ -65,7 +87,9 @@ export function parseExport( // The parser's message would quote the value. } if (!Array.isArray(cookies)) return undefined - return (cookies as Stored[]).filter((cookie) => { + const read = cookies.map(storedOf) + if (read.some((cookie) => cookie === undefined)) return undefined + return (read as Stored[]).filter((cookie) => { const expires = expiresOf(cookie) return keep(hostOf(cookie)) && (expires <= 0 || expires >= now) }) From ea5ee7a52b972531aa65940eda3552ce2de87463 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 22:26:36 +0300 Subject: [PATCH 074/103] fix(dev): fail when Google holds the popup on a host no step drives The Google driver now watches every google.com host. One other than accounts.google.com that keeps the popup for 5 s fails the run, naming its host and path, instead of idling to the driver's budget. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/google.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/ts/apps/dev/e2e/google.ts b/ts/apps/dev/e2e/google.ts index ea684aff..cb37a753 100644 --- a/ts/apps/dev/e2e/google.ts +++ b/ts/apps/dev/e2e/google.ts @@ -1,5 +1,9 @@ import type { Page } from '@playwright/test' import { clickFound, drivePopup, evaluateWithin } from './popup.ts' +import { onGoogle } from './session.ts' + +/** How long the popup may stay on a Google host other than accounts.google.com. */ +const ELSEWHERE_GRACE = 5_000 /** Pages that no automation gets past: the session or the OAuth client needs a person. */ const REFUSED: [string, string][] = [ @@ -33,10 +37,21 @@ const findNext = (email: string) => `(() => { */ export async function authorizeOnGoogle(popup: Page, email: string) { let clickedAt = 0 + let elsewhereSince: number | undefined await drivePopup(popup, { name: 'Google', - onHost: (url) => url.hostname === 'accounts.google.com', + onHost: (url) => onGoogle(url.hostname), step: async (url, text) => { + // Another Google host has no handler: fail on one that stays, naming it. + if (url.hostname !== 'accounts.google.com') { + elsewhereSince ??= Date.now() + if (Date.now() - elsewhereSince > ELSEWHERE_GRACE) + throw new Error( + `Google left the popup on ${url.hostname}${url.pathname}, which no step drives`, + ) + return + } + elsewhereSince = undefined const refused = REFUSED.find(([marker]) => text.includes(marker)) if (refused) throw new Error(`${refused[1]} (${url.pathname})`) if (url.pathname.includes('/challenge/')) From 391c1050c3f15b118517a6003cf2833657c91e0e Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 22:26:36 +0300 Subject: [PATCH 075/103] fix(dev): an empty LIBID_LIVE_PLATFORMS selects no platform Only an unset LIBID_LIVE_PLATFORMS means every platform; one set empty selects none, which fails under LIBID_REQUIRE_LIVE. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/settings.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ts/apps/dev/e2e/settings.ts b/ts/apps/dev/e2e/settings.ts index bdbe9102..114be091 100644 --- a/ts/apps/dev/e2e/settings.ts +++ b/ts/apps/dev/e2e/settings.ts @@ -63,6 +63,8 @@ const RUST_SUITE_ONLY = [ export type Lookup = (name: string) => string | undefined const environment: Lookup = (name) => process.env[name] || undefined +/** Whether `name` is absent from the environment, as opposed to set empty. */ +const unsetInEnvironment = (name: string) => process.env[name] === undefined /** Where pnpm was invoked: what a relative path in a setting is relative to. */ const invoked = (get: Lookup) => get('INIT_CWD') ?? process.cwd() @@ -85,7 +87,7 @@ export function loadEnvFile(file: string): string[] { } export interface Tooling { - /** LIBID_LIVE_PLATFORMS, trimmed and checked, or every supported platform. */ + /** LIBID_LIVE_PLATFORMS, trimmed and checked, or every platform when it is unset. */ platforms: readonly PlatformId[] /** A missing setting fails the run instead of skipping its test. */ requireLive: boolean @@ -99,8 +101,9 @@ export interface Tooling { /** A switch: on for `1` or `true`, in any case; off for anything else. */ const flag = (get: Lookup, name: string) => /^(1|true)$/i.test(get(name) ?? '') -export function tooling(get: Lookup = environment): Tooling { - const listed = get('LIBID_LIVE_PLATFORMS') +export function tooling(get: Lookup = environment, unset = unsetInEnvironment): Tooling { + // Set but empty selects no platform; only an unset variable means all. + const listed = get('LIBID_LIVE_PLATFORMS') ?? (unset('LIBID_LIVE_PLATFORMS') ? undefined : '') const names = listed ?.split(',') .map((name) => name.trim()) From 8fc476c8409a5b9ec4434cc92e6e975593bd6adb Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 22:26:48 +0300 Subject: [PATCH 076/103] fix(dev): bound the live stack's teardown; keep its log out of CI's - The live webServer runs `pnpm build:deps && exec node services.ts --app`, so Playwright's SIGTERM reaches services.ts, which brings its Compose project down within STACK_STOP; a pnpm wrapper exited first and left the teardown racing the next run. - Under CI the config requires LIBID_STACK_LOGS: the containers' output goes only there, and the job log keeps the build's and services.ts' own messages. - services.ts appends to LIBID_STACK_LOGS. A relative path resolves against INIT_CWD; the live config passes it resolved as the other path settings are, next to the env file when there is one. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/settings.ts | 14 +++++++++++--- ts/apps/dev/playwright.live.config.ts | 16 ++++++++++++---- ts/apps/dev/services.ts | 9 ++++++--- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/ts/apps/dev/e2e/settings.ts b/ts/apps/dev/e2e/settings.ts index 114be091..371ff4b8 100644 --- a/ts/apps/dev/e2e/settings.ts +++ b/ts/apps/dev/e2e/settings.ts @@ -86,12 +86,21 @@ export function loadEnvFile(file: string): string[] { return Object.keys(values) } +/** A path a setting names: a relative one is the settings file's neighbour, else relative to where pnpm was invoked. */ +function settingPath(get: Lookup, path: string | undefined): string | undefined { + if (!path) return undefined + const settingsFile = envFile(get) + return resolve(settingsFile ? dirname(settingsFile) : invoked(get), path) +} + export interface Tooling { /** LIBID_LIVE_PLATFORMS, trimmed and checked, or every platform when it is unset. */ platforms: readonly PlatformId[] /** A missing setting fails the run instead of skipping its test. */ requireLive: boolean proxy?: string + /** LIBID_STACK_LOGS, resolved as a path setting. */ + stackLogs?: string /** Show the browser. */ headed: boolean /** A CI run: no traces or videos, the stack's output kept off the job log. */ @@ -115,6 +124,7 @@ export function tooling(get: Lookup = environment, unset = unsetInEnvironment): platforms: (names as PlatformId[] | undefined) ?? PLATFORMS, requireLive: flag(get, 'LIBID_REQUIRE_LIVE'), proxy: get('LIBID_TEST_PROXY'), + stackLogs: settingPath(get, get('LIBID_STACK_LOGS')), headed: flag(get, 'HEADED'), ci: flag(get, 'CI'), } @@ -204,9 +214,7 @@ function savedSession(prefix: string, get: Lookup): SavedSession | undefined { if (encoded) return { encoded } const file = get(`${prefix}_COOKIES_FILE`) if (!file) return undefined - // A relative path is the settings file's neighbour, else relative to where pnpm was invoked. - const settingsFile = envFile(get) - return { path: resolve(settingsFile ? dirname(settingsFile) : invoked(get), file) } + return { path: settingPath(get, file)! } } export const xSession = (get: Lookup = environment) => savedSession('X_TEST_ALICE', get) diff --git a/ts/apps/dev/playwright.live.config.ts b/ts/apps/dev/playwright.live.config.ts index 550d8b15..ea47deaf 100644 --- a/ts/apps/dev/playwright.live.config.ts +++ b/ts/apps/dev/playwright.live.config.ts @@ -14,7 +14,11 @@ if (file) { console.warn(`${file}: no live test reads ${typos.join(', ')}; check the spelling.`) } if (runner) checkSettings() -const { proxy, headed, ci } = tooling() +const { proxy, headed, ci, stackLogs } = tooling() +// The services' output carries what the job log must not: CI writes it to a +// file it scans before upload. +if (ci && !stackLogs) + throw new Error("CI runs need LIBID_STACK_LOGS: a file for the services' output") /** Live: real platforms, real notary, real Bridge; no chain. */ export default defineConfig({ @@ -27,13 +31,17 @@ export default defineConfig({ // One command: start the services and the app, test, then stop both. The // run never reuses a running stack: services.ts refuses taken ports. webServer: { - command: 'pnpm dev', + // exec: Playwright's SIGTERM reaches services.ts, which brings the stack down. + command: 'pnpm build:deps && exec node services.ts --app', + ...(stackLogs && { + env: { ...(process.env as Record), LIBID_STACK_LOGS: stackLogs }, + }), url: APP_ORIGIN, reuseExistingServer: false, timeout: STACK_START, gracefulShutdown: { signal: 'SIGTERM', timeout: STACK_STOP }, - // CI keeps the services' output off the public job log: services.ts - // writes it to LIBID_STACK_LOGS, which is scanned before upload. + // Under CI the containers' output goes to LIBID_STACK_LOGS; the build's + // and services.ts' own messages stay on stderr. stdout: ci ? 'ignore' : 'pipe', }, use: { diff --git a/ts/apps/dev/services.ts b/ts/apps/dev/services.ts index 948ef7bb..e3cbaa70 100644 --- a/ts/apps/dev/services.ts +++ b/ts/apps/dev/services.ts @@ -1,10 +1,11 @@ // Start the real Bridge, notary and emitted CCDP; no OAuth mocks. -// LIBID_STACK_LOGS names a file that takes every child's output, containers included. +// LIBID_STACK_LOGS names a file that takes every child's output, containers +// included, appended; a relative path is relative to where pnpm was invoked. import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { openSync } from 'node:fs' import { connect } from 'node:net' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import { createServer as createViteServer, type ViteDevServer } from 'vite' @@ -14,7 +15,9 @@ const root = fileURLToPath(new URL('.', import.meta.url)) // Distinct Compose ownership for each checkout. No shared container names. const project = `libid-dev-${createHash('sha256').update(root).digest('hex').slice(0, 12)}` const composeArgs = ['compose', '-p', project, '-f', join(root, 'compose.yaml')] -const logs = process.env.LIBID_STACK_LOGS ? openSync(process.env.LIBID_STACK_LOGS, 'w') : undefined +const logs = process.env.LIBID_STACK_LOGS + ? openSync(resolve(process.env.INIT_CWD ?? process.cwd(), process.env.LIBID_STACK_LOGS), 'a') + : undefined const stdio = logs === undefined ? 'inherit' : (['ignore', logs, logs] as ['ignore', number, number]) /** Whether something on this host accepts connections on `port`. */ From e34cc946d3c8a71f0a6c9d5bb61251c07baa12a3 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 22:26:48 +0300 Subject: [PATCH 077/103] fix(dev): fail the secret scan closed; test it with fake markers - Under CI the scan fails when it has no secret to look for, as a failure-output step given none of the job's secrets would. - settings.test.ts checks that each job's failure-output step is given every scanned secret (SCANNED) its test step is. - scan-secrets.test.ts runs the scan on logs holding fake markers: plain, URL-encoded and JSON-escaped values, a localStorage value, a session set but undecodable, the 8-character minimum, a clean log, and CI with nothing to look for. The dev test script runs every e2e/*.test.ts. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/scan-secrets.test.ts | 61 ++++++++++++++++++++++++++++ ts/apps/dev/e2e/scan-secrets.ts | 11 ++++- ts/apps/dev/e2e/settings.test.ts | 28 +++++++++---- ts/apps/dev/e2e/settings.ts | 10 +++++ ts/apps/dev/package.json | 2 +- 5 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 ts/apps/dev/e2e/scan-secrets.test.ts diff --git a/ts/apps/dev/e2e/scan-secrets.test.ts b/ts/apps/dev/e2e/scan-secrets.test.ts new file mode 100644 index 00000000..312ee330 --- /dev/null +++ b/ts/apps/dev/e2e/scan-secrets.test.ts @@ -0,0 +1,61 @@ +// scan-secrets.ts run as CI runs it, against logs holding fake markers. +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { after, test } from 'node:test' + +const SCRIPT = new URL('./scan-secrets.ts', import.meta.url).pathname +const directory = mkdtempSync(join(tmpdir(), 'scan-secrets-')) +after(() => rmSync(directory, { recursive: true, force: true })) +const base64 = (text: string) => Buffer.from(text).toString('base64') + +/** The scan's exit code for a log holding `log`, with `env` as the step's settings. */ +function scan(log: string, env: Record): number | null { + const file = join(directory, `${Math.random().toString(36).slice(2)}.log`) + writeFileSync(file, log) + const clean = Object.fromEntries( + Object.entries(process.env).filter(([name]) => !/_TEST_ALICE_|^LIBID_|^CI$/.test(name)), + ) + return spawnSync(process.execPath, [SCRIPT, file], { env: { ...clean, ...env } }).status +} + +const storageState = base64( + JSON.stringify({ + cookies: [{ name: 'SID', value: 'FAKE/MARKER=123', domain: '.google.com' }], + origins: [ + { + origin: 'https://accounts.google.com', + localStorage: [{ name: 'k', value: 'LSVALUE-98765' }], + }, + ], + }), +) + +test('a secret in the log fails the scan, in each form it is looked for', () => { + const password = { GH_TEST_ALICE_PASSWORD: 'FAKE"QUOTE-12345' } + assert.equal(scan('x FAKE"QUOTE-12345 y', password), 1) + assert.equal(scan('x FAKE\\"QUOTE-12345 y', password), 1) + assert.equal(scan('x FAKE%22QUOTE-12345 y', password), 1) + const google = { GOOGLE_TEST_ALICE_COOKIES: storageState } + assert.equal(scan('x FAKE%2FMARKER%3D123 y', google), 1) + assert.equal(scan('x LSVALUE-98765 y', google), 1) + assert.equal(scan(`x ${storageState} y`, google), 1) +}) + +test('an undecodable session is still looked for by its value strings', () => { + const truncated = base64('[{"name":"auth_token","value":"FAKEMARKER-123456"') + assert.equal(scan('x FAKEMARKER-123456 y', { X_TEST_ALICE_COOKIES: truncated }), 1) + assert.equal(scan('clean', { X_TEST_ALICE_COOKIES: '!!!not base64' }), 0) +}) + +test('values under 8 characters are not looked for; a clean log passes', () => { + assert.equal(scan('x SHORT-7 y', { GH_TEST_ALICE_PASSWORD: 'SHORT-7' }), 0) + assert.equal(scan('clean', { GH_TEST_ALICE_PASSWORD: 'FAKE-PASSWORD-1' }), 0) +}) + +test('under CI, a scan with no secret to look for fails', () => { + assert.equal(scan('clean', { CI: 'true' }), 1) + assert.equal(scan('clean', {}), 0) +}) diff --git a/ts/apps/dev/e2e/scan-secrets.ts b/ts/apps/dev/e2e/scan-secrets.ts index c48f9119..5a44c30a 100644 --- a/ts/apps/dev/e2e/scan-secrets.ts +++ b/ts/apps/dev/e2e/scan-secrets.ts @@ -3,10 +3,11 @@ // (its cookies' and, for a storage state, its origins' localStorage values), // each also URL-encoded and JSON-escaped. A session that does not decode is // still looked for as set. Paths are the arguments; a missing file is -// skipped. Nothing secret is printed. +// skipped. Under CI, finding no secret to look for fails. Nothing secret is +// printed. import { existsSync, readFileSync } from 'node:fs' import { exportText } from './session.ts' -import { githubSecrets, googleSession, type SavedSession, xSession } from './settings.ts' +import { githubSecrets, googleSession, type SavedSession, tooling, xSession } from './settings.ts' /** The `value` strings of `items`, where it is a list. */ const values = (items: unknown) => @@ -58,6 +59,12 @@ const secrets = [ ), ] +// Under CI a scan with nothing to look for would pass anything: the step lacks its secrets. +if (tooling().ci && !secrets.length) { + console.error("No test secret to look for: give this step the job's secrets (see live-e2e.yml)") + process.exit(1) +} + const leaking = process.argv .slice(2) .filter(existsSync) diff --git a/ts/apps/dev/e2e/settings.test.ts b/ts/apps/dev/e2e/settings.test.ts index 49aba930..fda8e4eb 100644 --- a/ts/apps/dev/e2e/settings.test.ts +++ b/ts/apps/dev/e2e/settings.test.ts @@ -14,7 +14,7 @@ import { STACK_STOP, TEST, } from './budgets.ts' -import { missing, type Need, PLATFORM_SECTIONS, VARIABLES } from './settings.ts' +import { missing, type Need, PLATFORM_SECTIONS, SCANNED, VARIABLES } from './settings.ts' const read = (relative: string) => readFileSync(new URL(relative, import.meta.url), 'utf8') const workflow = read('../../../../.github/workflows/live-e2e.yml') @@ -36,22 +36,25 @@ function envEntry(line: string): [string, string] | undefined { const referenced = (text: string) => new Set([...text.matchAll(/\b(?:secrets|vars)\.([A-Za-z0-9_]+)/g)].map((match) => match[1]!)) -/** The lines of job `name`'s test step (`id: test`). */ -function testStep(name: string): string[] { +/** The lines of job `name`'s step holding the line `marker`. */ +function step(name: string, marker: string): string[] { const lines = workflow.split('\njobs:\n')[1]!.split('\n') const start = lines.indexOf(` ${name}:`) assert.notEqual(start, -1, `the workflow has a job ${name}`) const end = lines.findIndex((line, index) => index > start && /^ {2}[a-z][\w-]*:$/.test(line)) const job = lines.slice(start, end === -1 ? undefined : end) - const id = job.indexOf(' id: test') - assert.notEqual(id, -1, `job ${name} has a step with id test`) + const at = job.findIndex((line) => line.trim() === marker) + assert.notEqual(at, -1, `job ${name} has a step with ${marker}`) const isStep = (line: string) => line.startsWith(' - ') - let first = id + let first = at while (first > 0 && !isStep(job[first]!)) first-- - const next = job.findIndex((line, index) => index > id && isStep(line)) + const next = job.findIndex((line, index) => index > at && isStep(line)) return job.slice(first, next === -1 ? undefined : next) } +/** The lines of job `name`'s test step (`id: test`). */ +const testStep = (name: string) => step(name, 'id: test') + test('every secret the workflow reads is a setting, passed under its own name', () => { for (const name of [...workflow.matchAll(/\bsecrets\.([A-Za-z0-9_]+)/g)].map((m) => m[1]!)) assert.ok(isSetting(name), `the workflow reads secret ${name}, which the settings do not list`) @@ -97,6 +100,17 @@ test("each job's test step is given what every platform it can select requires", } }) +test("each job's failure-output step is given every scanned secret its test step is", () => { + for (const name of JOBS) { + const tested = new Set(entriesOf(testStep(name)).map(([key]) => key)) + const output = new Set( + entriesOf(step(name, 'uses: ./.github/actions/live-e2e-output')).map(([key]) => key), + ) + const absent = SCANNED.filter((secret) => tested.has(secret) && !output.has(secret)) + assert.deepEqual(absent, [], `job ${name}'s failure-output step lacks ${absent.join(', ')}`) + } +}) + test('each test step outlasts its worst case: the stack, every platform it can select', () => { const minutes = (ms: number) => ms / 60_000 const sum = NAVIGATION + READY + ACTION + DRIVER + OUTCOME diff --git a/ts/apps/dev/e2e/settings.ts b/ts/apps/dev/e2e/settings.ts index 371ff4b8..e1713489 100644 --- a/ts/apps/dev/e2e/settings.ts +++ b/ts/apps/dev/e2e/settings.ts @@ -194,6 +194,16 @@ export const githubAccount = (get: Lookup = environment) => totpSecret: get('GH_TEST_ALICE_TOTP_SECRET')!, })) +/** The variables whose values the failure-output scan looks for. */ +export const SCANNED = [ + 'GH_TEST_ALICE_PASSWORD', + 'GH_TEST_ALICE_TOTP_SECRET', + 'X_TEST_ALICE_COOKIES', + 'X_TEST_ALICE_COOKIES_FILE', + 'GOOGLE_TEST_ALICE_COOKIES', + 'GOOGLE_TEST_ALICE_COOKIES_FILE', +] as const + /** The GitHub account's secrets that are set, for the failure-output scan. */ export const githubSecrets = (get: Lookup = environment) => [get('GH_TEST_ALICE_PASSWORD'), get('GH_TEST_ALICE_TOTP_SECRET')].filter( diff --git a/ts/apps/dev/package.json b/ts/apps/dev/package.json index 1b0ae1e2..7dee6ab0 100644 --- a/ts/apps/dev/package.json +++ b/ts/apps/dev/package.json @@ -13,7 +13,7 @@ "build:deps": "pnpm --filter '@libid/dev^...' build", "build": "vite build", "typecheck": "tsc --noEmit", - "test": "node --test local.test.ts e2e/settings.test.ts e2e/session.test.ts", + "test": "node --test local.test.ts e2e/*.test.ts", "test:e2e": "playwright test", "test:live": "playwright test -c playwright.live.config.ts" }, From a177f1818131d017cbec4a2c27bc6b568d4a95b4 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Thu, 22 Oct 2026 22:26:48 +0300 Subject: [PATCH 078/103] docs(dev): ignore .env.test repo-wide; document the stack log and CI rows .gitignore ignores .env.test, *.env.test and .env.*.local everywhere; the README says to keep the env file outside the repository or name it .env.test, and describes LIBID_STACK_LOGS (appended, its path rule, required under CI), CI's effect on the job log, and an empty LIBID_LIVE_PLATFORMS. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .gitignore | 5 ++++- ts/apps/dev/README.md | 9 +++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 62d59993..763af9aa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,12 @@ ts/packages/*/node_modules/ ts/apps/*/node_modules/ ts/packages/*/dist/ ts/apps/*/dist/ -# Local development environment files +# Local development environment files and test secrets .env .env.local +.env.*.local +.env.test +*.env.test # @libid/popup e2e bundles and Playwright output ts/packages/popup/e2e/dist/ diff --git a/ts/apps/dev/README.md b/ts/apps/dev/README.md index 54009c36..881f9244 100644 --- a/ts/apps/dev/README.md +++ b/ts/apps/dev/README.md @@ -120,7 +120,8 @@ The variables, read through [e2e/settings.ts](e2e/settings.ts), carry the names libid-server-rs' ceremony-tests uses, so one `.env.test` serves both suites. With `LIBID_REQUIRE_LIVE`, the run checks every selected platform's variables at once and names each one absent; without it, a test lacking its -variables is skipped. A switch is on for `1` or `true`, in any case. A relative +variables is skipped. A switch is on for `1` or `true`, in any case. Keep the env file outside +the repository, or name it `.env.test`, which git ignores. A relative `*_COOKIES_FILE` path is relative to the env file, or, without one, to where you run pnpm. Keys in the env file that look like settings but that neither suite reads are reported as likely typos. @@ -137,9 +138,9 @@ neither suite reads are reported as likely typos. | `GOOGLE_TEST_ALICE_COOKIES` | google-session | required | Its saved session, as for X. | | `GOOGLE_TEST_ALICE_COOKIES_FILE` | google-session | instead of `GOOGLE_TEST_ALICE_COOKIES` | A path to that JSON, as for X. | | `LIBID_TEST_ENV_FILE` | tooling | optional | A dotenv file with the variables above, relative to where you run pnpm; exported values win. | -| `LIBID_LIVE_PLATFORMS` | tooling | optional | Comma-separated platforms to prove, such as `github,x`; all three by default. | +| `LIBID_LIVE_PLATFORMS` | tooling | optional | Comma-separated platforms to prove, such as `github,x`; all three when unset, none when set empty. | | `LIBID_REQUIRE_LIVE` | tooling | optional | `1` or `true`: a missing variable or an empty platform list fails instead of skipping. | | `LIBID_TEST_PROXY` | tooling | optional | A proxy for the browser's traffic, loopback aside. | -| `LIBID_STACK_LOGS` | tooling | optional | A file for the services' output, containers included, instead of the terminal. | +| `LIBID_STACK_LOGS` | tooling | optional | A file the services' output, containers included, is appended to instead of the terminal; a relative path as for `*_COOKIES_FILE`. Required under CI. | | `HEADED` | tooling | optional | `1` or `true`: show the browser. | -| `CI` | tooling | optional | `1` or `true`, as CI services set it: no traces or videos, and the services' output kept off the job log. | +| `CI` | tooling | optional | `1` or `true`, as CI services set it: no traces or videos; the containers' output goes only to `LIBID_STACK_LOGS`, and only the build's and services.ts' own messages reach the job log. | From 6f8d88d1dfcdd28e44d2545be231b59c4aed500f Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 08:41:19 +0300 Subject: [PATCH 079/103] fix(dev): one path rule and raw lookups for the live settings - paths.ts holds the path rule (a relative path is the env file's neighbour, else relative to where pnpm was invoked); settings.ts and services.ts both resolve through it, so LIBID_STACK_LOGS means the same file in `pnpm dev` and `test:live`. - A Lookup returns a value as set, empty included; settings.ts treats empty as absent itself, so an injected lookup can express "set but empty" for LIBID_LIVE_PLATFORMS without reading process.env. - loadEnvFile: an exported value wins over the env file; an exported empty value counts as unset, except LIBID_LIVE_PLATFORMS, where it selects no platform. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/settings.ts | 67 ++++++++++++++++--------------------- ts/apps/dev/paths.ts | 26 ++++++++++++++ ts/apps/dev/services.ts | 10 +++--- 3 files changed, 60 insertions(+), 43 deletions(-) create mode 100644 ts/apps/dev/paths.ts diff --git a/ts/apps/dev/e2e/settings.ts b/ts/apps/dev/e2e/settings.ts index e1713489..39fd898c 100644 --- a/ts/apps/dev/e2e/settings.ts +++ b/ts/apps/dev/e2e/settings.ts @@ -3,9 +3,9 @@ // are those of libid-server-rs' ceremony-tests (src/settings.rs), which reads // the same .env.test. An empty value is an absent one. import { readFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' import { parseEnv } from 'node:util' import type { PlatformId } from '@libid/ceremony' +import { envFile as envFileOf, type Lookup, nonEmpty, settingPath } from '../paths.ts' /** Whether a section needs a variable; `insteadOf` stands in for that required one. */ export type Need = 'required' | 'optional' | { insteadOf: string } @@ -59,39 +59,28 @@ const RUST_SUITE_ONLY = [ 'GOOGLE_TEST_ALICE_PASSWORD', ] -/** The value of a name, if it has one. */ -export type Lookup = (name: string) => string | undefined +export type { Lookup } from '../paths.ts' -const environment: Lookup = (name) => process.env[name] || undefined -/** Whether `name` is absent from the environment, as opposed to set empty. */ -const unsetInEnvironment = (name: string) => process.env[name] === undefined +const environment: Lookup = (name) => process.env[name] -/** Where pnpm was invoked: what a relative path in a setting is relative to. */ -const invoked = (get: Lookup) => get('INIT_CWD') ?? process.cwd() - -/** The settings file LIBID_TEST_ENV_FILE names, relative to where pnpm was invoked. */ -export function envFile(get: Lookup = environment): string | undefined { - const file = get('LIBID_TEST_ENV_FILE') - return file && resolve(invoked(get), file) -} +/** Exported variables an env file does not override even when set empty. */ +const EMPTY_WINS = ['LIBID_LIVE_PLATFORMS'] /** - * Read the settings file into the environment: a value already set wins, and - * an empty one counts as absent. Returns the file's keys. + * Read the settings file into the environment: an exported value wins; an + * empty one counts as absent, except for EMPTY_WINS. Returns the file's keys. */ export function loadEnvFile(file: string): string[] { const values = parseEnv(readFileSync(file, 'utf8')) - for (const [name, value] of Object.entries(values)) - if (environment(name) === undefined) process.env[name] = value + for (const [name, value] of Object.entries(values)) { + const exported = process.env[name] + if (exported === undefined || (exported === '' && !EMPTY_WINS.includes(name))) + process.env[name] = value + } return Object.keys(values) } -/** A path a setting names: a relative one is the settings file's neighbour, else relative to where pnpm was invoked. */ -function settingPath(get: Lookup, path: string | undefined): string | undefined { - if (!path) return undefined - const settingsFile = envFile(get) - return resolve(settingsFile ? dirname(settingsFile) : invoked(get), path) -} +export const envFile = (get: Lookup = environment) => envFileOf(get) export interface Tooling { /** LIBID_LIVE_PLATFORMS, trimmed and checked, or every platform when it is unset. */ @@ -108,11 +97,11 @@ export interface Tooling { } /** A switch: on for `1` or `true`, in any case; off for anything else. */ -const flag = (get: Lookup, name: string) => /^(1|true)$/i.test(get(name) ?? '') +const flag = (get: Lookup, name: string) => /^(1|true)$/i.test(nonEmpty(get, name) ?? '') -export function tooling(get: Lookup = environment, unset = unsetInEnvironment): Tooling { +export function tooling(get: Lookup = environment): Tooling { // Set but empty selects no platform; only an unset variable means all. - const listed = get('LIBID_LIVE_PLATFORMS') ?? (unset('LIBID_LIVE_PLATFORMS') ? undefined : '') + const listed = get('LIBID_LIVE_PLATFORMS') const names = listed ?.split(',') .map((name) => name.trim()) @@ -123,8 +112,8 @@ export function tooling(get: Lookup = environment, unset = unsetInEnvironment): return { platforms: (names as PlatformId[] | undefined) ?? PLATFORMS, requireLive: flag(get, 'LIBID_REQUIRE_LIVE'), - proxy: get('LIBID_TEST_PROXY'), - stackLogs: settingPath(get, get('LIBID_STACK_LOGS')), + proxy: nonEmpty(get, 'LIBID_TEST_PROXY'), + stackLogs: settingPath(get, nonEmpty(get, 'LIBID_STACK_LOGS')), headed: flag(get, 'HEADED'), ci: flag(get, 'CI'), } @@ -135,13 +124,15 @@ export const isLive = (platform: PlatformId) => tooling().platforms.includes(pla /** The required variables of `section` that `get` finds absent; `A (or B)` where B stands in. */ export function missing(section: Section, get: Lookup = environment): string[] { - return VARIABLES.filter(([name, of, need]) => of === section && need === 'required' && !get(name)) + return VARIABLES.filter( + ([name, of, need]) => of === section && need === 'required' && !nonEmpty(get, name), + ) .map(([name]) => { const standIn = VARIABLES.find( ([, , need]) => typeof need === 'object' && need.insteadOf === name, )?.[0] if (!standIn) return name - return get(standIn) ? undefined : `${name} (or ${standIn})` + return nonEmpty(get, standIn) ? undefined : `${name} (or ${standIn})` }) .filter((name): name is string => name !== undefined) } @@ -189,9 +180,9 @@ export interface GitHubAccount { export const githubAccount = (get: Lookup = environment) => read('github-account', get, () => ({ - username: get('GH_TEST_ALICE_USERNAME')!, - password: get('GH_TEST_ALICE_PASSWORD')!, - totpSecret: get('GH_TEST_ALICE_TOTP_SECRET')!, + username: nonEmpty(get, 'GH_TEST_ALICE_USERNAME')!, + password: nonEmpty(get, 'GH_TEST_ALICE_PASSWORD')!, + totpSecret: nonEmpty(get, 'GH_TEST_ALICE_TOTP_SECRET')!, })) /** The variables whose values the failure-output scan looks for. */ @@ -211,18 +202,18 @@ export const githubSecrets = (get: Lookup = environment) => ) export const xAccount = (get: Lookup = environment) => - read('x-account', get, () => ({ username: get('X_TEST_ALICE_USERNAME')! })) + read('x-account', get, () => ({ username: nonEmpty(get, 'X_TEST_ALICE_USERNAME')! })) export const googleAccount = (get: Lookup = environment) => - read('google-account', get, () => ({ email: get('GOOGLE_TEST_ALICE_EMAIL')! })) + read('google-account', get, () => ({ email: nonEmpty(get, 'GOOGLE_TEST_ALICE_EMAIL')! })) /** A saved session: base64 of the JSON cookie list, or a path to that JSON. */ export type SavedSession = { encoded: string } | { path: string } function savedSession(prefix: string, get: Lookup): SavedSession | undefined { - const encoded = get(`${prefix}_COOKIES`) + const encoded = nonEmpty(get, `${prefix}_COOKIES`) if (encoded) return { encoded } - const file = get(`${prefix}_COOKIES_FILE`) + const file = nonEmpty(get, `${prefix}_COOKIES_FILE`) if (!file) return undefined return { path: settingPath(get, file)! } } diff --git a/ts/apps/dev/paths.ts b/ts/apps/dev/paths.ts new file mode 100644 index 00000000..298f1137 --- /dev/null +++ b/ts/apps/dev/paths.ts @@ -0,0 +1,26 @@ +// Where a path setting points, for the live tests' settings and services.ts +// alike: a relative path is the settings file's neighbour when +// LIBID_TEST_ENV_FILE names one, else relative to where pnpm was invoked. +import { dirname, resolve } from 'node:path' + +/** The value of a name as set, empty included; undefined when unset. */ +export type Lookup = (name: string) => string | undefined + +/** A value, an empty one counting as absent. */ +export const nonEmpty = (get: Lookup, name: string) => get(name) || undefined + +/** Where pnpm was invoked. */ +const invoked = (get: Lookup) => nonEmpty(get, 'INIT_CWD') ?? process.cwd() + +/** The settings file LIBID_TEST_ENV_FILE names, relative to where pnpm was invoked. */ +export function envFile(get: Lookup): string | undefined { + const file = nonEmpty(get, 'LIBID_TEST_ENV_FILE') + return file && resolve(invoked(get), file) +} + +/** The path a setting names, resolved; undefined when it is unset or empty. */ +export function settingPath(get: Lookup, path: string | undefined): string | undefined { + if (!path) return undefined + const settingsFile = envFile(get) + return resolve(settingsFile ? dirname(settingsFile) : invoked(get), path) +} diff --git a/ts/apps/dev/services.ts b/ts/apps/dev/services.ts index e3cbaa70..3b2e4295 100644 --- a/ts/apps/dev/services.ts +++ b/ts/apps/dev/services.ts @@ -1,23 +1,23 @@ // Start the real Bridge, notary and emitted CCDP; no OAuth mocks. // LIBID_STACK_LOGS names a file that takes every child's output, containers -// included, appended; a relative path is relative to where pnpm was invoked. +// included, appended; a relative path resolves as paths.ts says. import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { openSync } from 'node:fs' import { connect } from 'node:net' -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import { createServer as createViteServer, type ViteDevServer } from 'vite' import { APP_ORIGIN, BRIDGE, NOTARY } from './local.ts' +import { settingPath } from './paths.ts' const root = fileURLToPath(new URL('.', import.meta.url)) // Distinct Compose ownership for each checkout. No shared container names. const project = `libid-dev-${createHash('sha256').update(root).digest('hex').slice(0, 12)}` const composeArgs = ['compose', '-p', project, '-f', join(root, 'compose.yaml')] -const logs = process.env.LIBID_STACK_LOGS - ? openSync(resolve(process.env.INIT_CWD ?? process.cwd(), process.env.LIBID_STACK_LOGS), 'a') - : undefined +const logPath = settingPath((name) => process.env[name], process.env.LIBID_STACK_LOGS) +const logs = logPath ? openSync(logPath, 'a') : undefined const stdio = logs === undefined ? 'inherit' : (['ignore', logs, logs] as ['ignore', number, number]) /** Whether something on this host accepts connections on `port`. */ From 6b1df265a06d873088070a16feb21ffc0ac7b00d Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 08:41:19 +0300 Subject: [PATCH 080/103] fix(dev): scan sessions as session.ts reads them; distinct exit codes - The scan read sessions with its own parser, which missed the capitalised `Value` spelling cookies.rs accepts: a log holding such a value passed. It now reads every cookie through session.ts' exportCookies, with no host or expiry filter, plus storage-state localStorage values; text that is no export falls back to its "value" strings, the key in any case. - The scanned variables are settings.ts' SCANNED, one list; a test sets each in turn and checks the scan finds its marker, and that a variable outside it is not looked for. - A found secret exits 2 and nothing to look for under CI exits 3, so the tests tell them from a crash (1); the workflow fails on any. - storedOf checks the optional fields' types as cookies.rs does (path, secure, httpOnly, sameSite, expires, partition key): a wrong type makes the export unreadable, never a TypeError. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/scan-secrets.test.ts | 57 +++++++++++++++----- ts/apps/dev/e2e/scan-secrets.ts | 79 ++++++++++++++++------------ ts/apps/dev/e2e/session.test.ts | 24 +++++++++ ts/apps/dev/e2e/session.ts | 63 ++++++++++++++++------ ts/apps/dev/e2e/settings.ts | 6 --- 5 files changed, 161 insertions(+), 68 deletions(-) diff --git a/ts/apps/dev/e2e/scan-secrets.test.ts b/ts/apps/dev/e2e/scan-secrets.test.ts index 312ee330..88e1a1f7 100644 --- a/ts/apps/dev/e2e/scan-secrets.test.ts +++ b/ts/apps/dev/e2e/scan-secrets.test.ts @@ -5,22 +5,37 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { after, test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { SCANNED } from './settings.ts' -const SCRIPT = new URL('./scan-secrets.ts', import.meta.url).pathname +const SCRIPT = fileURLToPath(new URL('./scan-secrets.ts', import.meta.url)) +const FOUND = 2 +const NOTHING_TO_LOOK_FOR = 3 const directory = mkdtempSync(join(tmpdir(), 'scan-secrets-')) after(() => rmSync(directory, { recursive: true, force: true })) const base64 = (text: string) => Buffer.from(text).toString('base64') +/** A file in the test's directory holding `text`. */ +function file(text: string): string { + const path = join(directory, Math.random().toString(36).slice(2)) + writeFileSync(path, text) + return path +} + /** The scan's exit code for a log holding `log`, with `env` as the step's settings. */ function scan(log: string, env: Record): number | null { - const file = join(directory, `${Math.random().toString(36).slice(2)}.log`) - writeFileSync(file, log) const clean = Object.fromEntries( Object.entries(process.env).filter(([name]) => !/_TEST_ALICE_|^LIBID_|^CI$/.test(name)), ) - return spawnSync(process.execPath, [SCRIPT, file], { env: { ...clean, ...env } }).status + const run = spawnSync(process.execPath, [SCRIPT, file(log)], { + env: { ...clean, ...env }, + encoding: 'utf8', + }) + if (run.status === FOUND) assert.match(run.stderr, /^Secrets in: /) + return run.status } +const cookies = (value: string) => JSON.stringify([{ name: 'SID', value, domain: '.google.com' }]) const storageState = base64( JSON.stringify({ cookies: [{ name: 'SID', value: 'FAKE/MARKER=123', domain: '.google.com' }], @@ -33,20 +48,36 @@ const storageState = base64( }), ) +test('the scan looks for exactly the SCANNED variables', () => { + for (const name of SCANNED) { + const env = name.endsWith('_COOKIES_FILE') + ? { [name]: file(cookies(`MARKER-${name}`)) } + : name.endsWith('_COOKIES') + ? { [name]: base64(cookies(`MARKER-${name}`)) } + : { [name]: `MARKER-${name}` } + assert.equal(scan(`x MARKER-${name} y`, env), FOUND, name) + } + assert.equal(scan('x MARKER-USERNAME y', { GH_TEST_ALICE_USERNAME: 'MARKER-USERNAME' }), 0) +}) + test('a secret in the log fails the scan, in each form it is looked for', () => { const password = { GH_TEST_ALICE_PASSWORD: 'FAKE"QUOTE-12345' } - assert.equal(scan('x FAKE"QUOTE-12345 y', password), 1) - assert.equal(scan('x FAKE\\"QUOTE-12345 y', password), 1) - assert.equal(scan('x FAKE%22QUOTE-12345 y', password), 1) + assert.equal(scan('x FAKE"QUOTE-12345 y', password), FOUND) + assert.equal(scan('x FAKE\\"QUOTE-12345 y', password), FOUND) + assert.equal(scan('x FAKE%22QUOTE-12345 y', password), FOUND) const google = { GOOGLE_TEST_ALICE_COOKIES: storageState } - assert.equal(scan('x FAKE%2FMARKER%3D123 y', google), 1) - assert.equal(scan('x LSVALUE-98765 y', google), 1) - assert.equal(scan(`x ${storageState} y`, google), 1) + assert.equal(scan('x FAKE%2FMARKER%3D123 y', google), FOUND) + assert.equal(scan('x LSVALUE-98765 y', google), FOUND) + assert.equal(scan(`x ${storageState} y`, google), FOUND) + const capitalised = base64('[{"Name":"SID","Value":"CAPITAL-VALUE-1","Domain":".google.com"}]') + assert.equal(scan('x CAPITAL-VALUE-1 y', { GOOGLE_TEST_ALICE_COOKIES: capitalised }), FOUND) }) test('an undecodable session is still looked for by its value strings', () => { - const truncated = base64('[{"name":"auth_token","value":"FAKEMARKER-123456"') - assert.equal(scan('x FAKEMARKER-123456 y', { X_TEST_ALICE_COOKIES: truncated }), 1) + const truncated = base64('[{"Name":"auth_token","Value":"FAKEMARKER-123456"') + assert.equal(scan('x FAKEMARKER-123456 y', { X_TEST_ALICE_COOKIES: truncated }), FOUND) + const badEntry = base64('[{"name":"a","value":"BADENTRY-123456","domain":1}]') + assert.equal(scan('x BADENTRY-123456 y', { X_TEST_ALICE_COOKIES: badEntry }), FOUND) assert.equal(scan('clean', { X_TEST_ALICE_COOKIES: '!!!not base64' }), 0) }) @@ -56,6 +87,6 @@ test('values under 8 characters are not looked for; a clean log passes', () => { }) test('under CI, a scan with no secret to look for fails', () => { - assert.equal(scan('clean', { CI: 'true' }), 1) + assert.equal(scan('clean', { CI: 'true' }), NOTHING_TO_LOOK_FOR) assert.equal(scan('clean', {}), 0) }) diff --git a/ts/apps/dev/e2e/scan-secrets.ts b/ts/apps/dev/e2e/scan-secrets.ts index 5a44c30a..eff6e36c 100644 --- a/ts/apps/dev/e2e/scan-secrets.ts +++ b/ts/apps/dev/e2e/scan-secrets.ts @@ -1,47 +1,60 @@ // Refuse to publish a failed run's stack log when it holds a test secret: the -// GitHub password or TOTP secret, a saved session as set, or a value it holds -// (its cookies' and, for a storage state, its origins' localStorage values), -// each also URL-encoded and JSON-escaped. A session that does not decode is -// still looked for as set. Paths are the arguments; a missing file is -// skipped. Under CI, finding no secret to look for fails. Nothing secret is -// printed. +// value of each variable settings.ts' SCANNED lists, and for a saved session +// every value it holds (its cookies' and, for a storage state, its origins' +// localStorage values), each also URL-encoded and JSON-escaped. A session +// that does not decode is still looked for as set. Paths are the arguments; a +// missing file is skipped. Exit 2: a secret was found; 3: under CI, there was +// none to look for. Nothing secret is printed. import { existsSync, readFileSync } from 'node:fs' -import { exportText } from './session.ts' -import { githubSecrets, googleSession, type SavedSession, tooling, xSession } from './settings.ts' +import { settingPath } from '../paths.ts' +import { exportCookies } from './session.ts' +import { SCANNED, tooling } from './settings.ts' + +const environment = (name: string) => process.env[name] /** The `value` strings of `items`, where it is a list. */ const values = (items: unknown) => Array.isArray(items) ? items.map((item) => (item as { value?: unknown } | null)?.value) : [] +/** Every `"value": "…"` string `text` holds, in any spelling of the key. */ +const valueStrings = (text: string) => + [...text.matchAll(/"value"\s*:\s*"([^"\\]*)"/gi)].map((match) => match[1]) + /** - * The session setting's own value, and the values it holds: from the JSON - * where it parses, else every `"value": "…"` string its text holds. + * The values a session export holds: its cookies' and its origins' + * localStorage; where it is not JSON or no cookie list, its value strings. */ -function sessionSecrets(saved: SavedSession | undefined): string[] { - if (!saved) return [] - const raw = 'encoded' in saved ? [saved.encoded.trim()] : [] - let text: string +function heldValues(text: string): unknown[] { + let parsed: unknown try { - text = exportText(saved) + parsed = JSON.parse(text) } catch { - return raw + return valueStrings(text) } - let held: unknown[] + const cookies = exportCookies(parsed) + const origins = (parsed as { origins?: unknown } | null)?.origins + return [ + ...(cookies ? cookies.map((cookie) => cookie.value) : valueStrings(text)), + ...(Array.isArray(origins) ? origins : []).flatMap((origin) => + values((origin as { localStorage?: unknown } | null)?.localStorage), + ), + ] +} + +/** What to look for of SCANNED variable `name`: its value, and a session's held values. */ +function secretsOf(name: string): unknown[] { + const value = environment(name) + if (!value) return [] + let text: string try { - const parsed = JSON.parse(text) as unknown - const state = parsed as { cookies?: unknown; origins?: unknown } | null - held = Array.isArray(parsed) - ? values(parsed) - : [ - ...values(state?.cookies), - ...(Array.isArray(state?.origins) ? state.origins : []).flatMap((origin) => - values((origin as { localStorage?: unknown } | null)?.localStorage), - ), - ] + if (name.endsWith('_COOKIES_FILE')) + return heldValues(readFileSync(settingPath(environment, value)!, 'utf8')) + if (!name.endsWith('_COOKIES')) return [value] + text = Buffer.from(value.trim(), 'base64').toString('utf8') } catch { - held = [...text.matchAll(/"value"\s*:\s*"([^"\\]*)"/g)].map((match) => match[1]) + return [] } - return [...raw, ...held.filter((value): value is string => typeof value === 'string')] + return [value.trim(), ...heldValues(text)] } /** A secret as written, URL-encoded and JSON-escaped. */ @@ -53,8 +66,8 @@ const forms = (secret: string) => [ const secrets = [ ...new Set( - [...githubSecrets(), ...sessionSecrets(xSession()), ...sessionSecrets(googleSession())] - .filter((secret) => secret.length >= 8) + SCANNED.flatMap(secretsOf) + .filter((secret): secret is string => typeof secret === 'string' && secret.length >= 8) .flatMap(forms), ), ] @@ -62,7 +75,7 @@ const secrets = [ // Under CI a scan with nothing to look for would pass anything: the step lacks its secrets. if (tooling().ci && !secrets.length) { console.error("No test secret to look for: give this step the job's secrets (see live-e2e.yml)") - process.exit(1) + process.exit(3) } const leaking = process.argv @@ -74,5 +87,5 @@ const leaking = process.argv }) if (leaking.length) { console.error(`Secrets in: ${leaking.join(', ')}`) - process.exit(1) + process.exit(2) } diff --git a/ts/apps/dev/e2e/session.test.ts b/ts/apps/dev/e2e/session.test.ts index 8129899d..b7283826 100644 --- a/ts/apps/dev/e2e/session.test.ts +++ b/ts/apps/dev/e2e/session.test.ts @@ -134,3 +134,27 @@ test('an export entry is a cookie only with string name, value and domain', () = ]) assert.equal(parseExport(bad, onX, NOW), undefined, bad) }) + +test('an optional field of the wrong type makes the export unreadable', () => { + for (const field of [ + '"path":1', + '"secure":"yes"', + '"httpOnly":1', + '"sameSite":1', + '"expires":"soon"', + '"partitionKey":1', + ]) + assert.equal( + parseExport(`[{"name":"a","value":"b","domain":".x.com",${field}}]`, onX, NOW), + undefined, + field, + ) + assert.equal( + parseExport( + '[{"name":"a","value":"b","domain":".x.com","secure":null,"sameSite":null}]', + onX, + NOW, + )?.length, + 1, + ) +}) diff --git a/ts/apps/dev/e2e/session.ts b/ts/apps/dev/e2e/session.ts index d15937ab..c761ac55 100644 --- a/ts/apps/dev/e2e/session.ts +++ b/ts/apps/dev/e2e/session.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import type { BrowserContext } from '@playwright/test' -import * as settings from './settings.ts' import type { SavedSession } from './settings.ts' +import * as settings from './settings.ts' /** * One cookie as libid-server-rs' `ceremony export` saves it (ceremony-tests @@ -32,7 +32,7 @@ interface PartitionKey { } /** The JSON text of a saved session setting. */ -export const exportText = (saved: SavedSession) => +const exportText = (saved: SavedSession) => 'encoded' in saved ? Buffer.from(saved.encoded.trim(), 'base64').toString('utf8') : readFileSync(saved.path, 'utf8') @@ -56,40 +56,71 @@ const CAPITALISED = { Secure: 'secure', } -/** One export entry as a cookie: an object with string name, value and domain; else undefined. */ +const isString = (value: unknown) => typeof value === 'string' +const isBoolean = (value: unknown) => typeof value === 'boolean' +const orNull = (check: (value: unknown) => boolean) => (value: unknown) => + value === null || check(value) + +/** The optional fields' types, as cookies.rs reads them; an absent field is fine. */ +const OPTIONAL: Record boolean> = { + path: isString, + secure: orNull(isBoolean), + http_only: isBoolean, + httpOnly: isBoolean, + same_site: orNull(isString), + sameSite: orNull(isString), + expires: (value) => typeof value === 'number', + expirationDate: (value) => typeof value === 'number', + partition_key: orNull((value) => isString(value) || typeof value === 'object'), + partitionKey: orNull((value) => isString(value) || typeof value === 'object'), +} + +/** + * One export entry as a cookie: an object with string name, value and domain, + * its optional fields of their types; else undefined. + */ function storedOf(entry: unknown): Stored | undefined { if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return undefined const cookie: Record = { ...entry } for (const [spelled, name] of Object.entries(CAPITALISED)) if (spelled in cookie && !(name in cookie)) cookie[name] = cookie[spelled] const { name, value, domain } = cookie - return typeof name === 'string' && typeof value === 'string' && typeof domain === 'string' - ? (cookie as unknown as Stored) - : undefined + if (!isString(name) || !isString(value) || !isString(domain)) return undefined + for (const [field, valid] of Object.entries(OPTIONAL)) + if (cookie[field] !== undefined && !valid(cookie[field])) return undefined + return cookie as unknown as Stored +} + +/** + * Every cookie of a parsed export, as cookies.rs reads them: a list, or one + * under `cookies`; undefined when it is no such export or an entry is no + * cookie. + */ +export function exportCookies(parsed: unknown): Stored[] | undefined { + const cookies = Array.isArray(parsed) ? parsed : (parsed as { cookies?: unknown } | null)?.cookies + if (!Array.isArray(cookies)) return undefined + const read = cookies.map(storedOf) + return read.some((cookie) => cookie === undefined) ? undefined : (read as Stored[]) } /** * The cookies of an export for the hosts `keep` admits, as the Rust suite's - * cookies::parse reads them: a list, or one under `cookies`, of entries with - * string name, value and domain; an expired cookie is left out. Undefined - * when the text is no such export. + * cookies::parse reads them (see exportCookies); an expired cookie is left + * out. Undefined when the text is no such export. */ export function parseExport( json: string, keep: (host: string) => boolean, now = Date.now() / 1000, ): Stored[] | undefined { - let cookies: unknown + let parsed: unknown try { - const parsed = JSON.parse(json) as unknown - cookies = Array.isArray(parsed) ? parsed : (parsed as { cookies?: unknown } | null)?.cookies + parsed = JSON.parse(json) } catch { // The parser's message would quote the value. + return undefined } - if (!Array.isArray(cookies)) return undefined - const read = cookies.map(storedOf) - if (read.some((cookie) => cookie === undefined)) return undefined - return (read as Stored[]).filter((cookie) => { + return exportCookies(parsed)?.filter((cookie) => { const expires = expiresOf(cookie) return keep(hostOf(cookie)) && (expires <= 0 || expires >= now) }) diff --git a/ts/apps/dev/e2e/settings.ts b/ts/apps/dev/e2e/settings.ts index 39fd898c..319a0712 100644 --- a/ts/apps/dev/e2e/settings.ts +++ b/ts/apps/dev/e2e/settings.ts @@ -195,12 +195,6 @@ export const SCANNED = [ 'GOOGLE_TEST_ALICE_COOKIES_FILE', ] as const -/** The GitHub account's secrets that are set, for the failure-output scan. */ -export const githubSecrets = (get: Lookup = environment) => - [get('GH_TEST_ALICE_PASSWORD'), get('GH_TEST_ALICE_TOTP_SECRET')].filter( - (secret): secret is string => secret !== undefined, - ) - export const xAccount = (get: Lookup = environment) => read('x-account', get, () => ({ username: nonEmpty(get, 'X_TEST_ALICE_USERNAME')! })) From 7099fb32824a3313f58445359a95ed69ff8e39d9 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 08:41:19 +0300 Subject: [PATCH 081/103] fix(dev): settle TOTP refusals; route GitHub by path first; one X host rule - The TOTP step reads the clock once for its step and code, and counts its form showing again as a refusal only 5 s after the submit and in a later step, so a slow redirect is not a refusal. - GitHub pages route by their known paths before the 2FA checkup's text, which the TOTP page could otherwise match. - The X driver recognises X's pages with session.ts' onX, the Rust suite's x_host; cookies are still set on x.com and twitter.com, as the Rust suite sets them. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/github.ts | 22 +++++++++++++++------- ts/apps/dev/e2e/x.ts | 7 ++----- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/ts/apps/dev/e2e/github.ts b/ts/apps/dev/e2e/github.ts index 594897d7..786cb882 100644 --- a/ts/apps/dev/e2e/github.ts +++ b/ts/apps/dev/e2e/github.ts @@ -10,6 +10,8 @@ const LOGIN_SETTLE = 15_000 /** Codes the TOTP step submits before a refusal fails the run. */ const TOTP_ATTEMPTS = 2 +/** How long a submitted code's form may still show before it counts as refused. */ +const TOTP_SETTLE = 5_000 /** The TOTP code for the base32 `secret`, spaces and case aside, at `time` (ms). */ export function totp(secret: string, time = Date.now()): string { @@ -33,6 +35,7 @@ interface Progress { /** The 30-second step whose code was last submitted, and how many were. */ totpStep?: number totpAttempts: number + totpSubmittedAt?: number /** When the authorize button was last clicked; it is not clicked again within 5 s. */ authorizedAt: number /** When the 2FA checkup was last skipped; it is not skipped again within 5 s. */ @@ -103,14 +106,18 @@ async function onLogin( } async function onTotp(popup: Page, account: GitHubAccount, progress: Progress, text: string) { - const step = Math.floor(Date.now() / 30_000) - if (progress.totpStep === step || !(await popup.locator('#app_totp').count())) return - // The code form again, in a later step, after a submitted code: GitHub refused it. + // One clock read for the step and its code. + const now = Date.now() + const step = Math.floor(now / 30_000) + if (!(await popup.locator('#app_totp').count())) return + // A submitted code gets time to land before its form showing counts as a refusal. + if (now - (progress.totpSubmittedAt ?? 0) < TOTP_SETTLE || progress.totpStep === step) return + // The code form still showing, settled and in a later step: GitHub refused the code. if (progress.totpAttempts >= TOTP_ATTEMPTS) throw new Error( `GitHub refused ${TOTP_ATTEMPTS} TOTP codes${text.includes('two-factor authentication failed') ? ' (two-factor authentication failed)' : ''}: check GH_TEST_ALICE_TOTP_SECRET and this host's clock`, ) - const code = totp(account.totpSecret) + const code = totp(account.totpSecret, now) try { await popup.fill('#app_totp', code, { timeout: STEP_TIMEOUT }) } catch { @@ -122,6 +129,7 @@ async function onTotp(popup: Page, account: GitHubAccount, progress: Progress, t .click({ timeout: 2_000 }) .catch(() => {}) progress.totpStep = step + progress.totpSubmittedAt = Date.now() progress.totpAttempts++ } @@ -163,9 +171,6 @@ async function onGitHubPage( text.includes('something went wrong') ) return onErrorPage(popup, progress, pause) - // GitHub's periodic 2FA checkup, served in place of the page asked for. - if (text.includes('verify your two-factor authentication')) - return onTwoFactorCheckup(popup, progress) if (path === '/login' || path === '/session') return onLogin(popup, account, progress, text, pause) if (path === '/sessions/two-factor/app') return onTotp(popup, account, progress, text) @@ -175,6 +180,9 @@ async function onGitHubPage( .catch(() => {}) return } + // GitHub's periodic 2FA checkup, served in place of the page asked for. + if (text.includes('verify your two-factor authentication')) + return onTwoFactorCheckup(popup, progress) 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') diff --git a/ts/apps/dev/e2e/x.ts b/ts/apps/dev/e2e/x.ts index 8d6f2833..25ff62e9 100644 --- a/ts/apps/dev/e2e/x.ts +++ b/ts/apps/dev/e2e/x.ts @@ -1,6 +1,6 @@ import type { BrowserContext, Page } from '@playwright/test' import { clickFound, drivePopup, evaluateWithin } from './popup.ts' -import { restoreSession, type Stored } from './session.ts' +import { onX, 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[]) => @@ -26,9 +26,6 @@ const FIND_CONSENT = `[...document.querySelectorAll("[data-testid='OAuth_Consent (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') - /** * Approve the OAuth app on X in `popup`, signed in by the saved session, until * X returns it to Bridge's callback. A port of libid-server-rs' ceremony-tests/src/browser/x.rs @@ -40,7 +37,7 @@ export async function authorizeOnX(popup: Page) { let challengedAt: number | undefined await drivePopup(popup, { name: 'X', - onHost: onX, + onHost: (url) => onX(url.hostname), step: async (url, text) => { if (CHALLENGE.some((marker) => text.includes(marker))) { challengedAt ??= Date.now() From 173aa67be5fad96fad45baf75c105f9a1772c7e8 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 08:41:19 +0300 Subject: [PATCH 082/103] fix(dev): stop the popup driver on the app's own failure; reuse fixtures - watchAppFailure lets the spec tell drivePopup when the app's run has failed; the driver then stops with the run's own message instead of waiting out its budget on the platform. - The spec uses app.fixtures.ts' launch, newestRun and a new expectReady (openApp uses it too), and watches popups for their return with a context 'page' listener added before the click, not a waitForEvent predicate with a side effect. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/ceremony.live.spec.ts | 28 ++++++++++++++------------- ts/apps/dev/e2e/popup.ts | 12 ++++++++++++ ts/apps/dev/src/app.fixtures.ts | 6 +++++- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/ts/apps/dev/e2e/ceremony.live.spec.ts b/ts/apps/dev/e2e/ceremony.live.spec.ts index 0aeb2ef4..1f763dc4 100644 --- a/ts/apps/dev/e2e/ceremony.live.spec.ts +++ b/ts/apps/dev/e2e/ceremony.live.spec.ts @@ -1,10 +1,11 @@ import type { PlatformId } from '@libid/ceremony' import { type BrowserContext, expect, type Page, test } from '@playwright/test' +import { expectReady, launch, newestRun } from '../src/app.fixtures.ts' import { OUTCOME, READY } from './budgets.ts' import { authorizeOnGitHub } from './github.ts' import { authorizeOnGoogle } from './google.ts' import { presentAsPerson } from './person.ts' -import { watchReturn } from './popup.ts' +import { watchAppFailure, watchReturn } from './popup.ts' import { googleSession, restoreSession, xSession } from './session.ts' import { githubAccount, @@ -56,16 +57,19 @@ async function proveThroughTheUi( ) { await page.goto('/') // A cold app loads the Bridge configuration and CCDP's version list first. - await expect(page.locator('#status')).toHaveText('Ready. Click a platform to start a ceremony.', { - timeout: READY, - }) - const [popup] = await Promise.all([ - // Watched from the moment it appears: a platform may return it at once. - context.waitForEvent('page', { predicate: (popup) => Boolean(watchReturn(popup)) }), - page.getByRole('button', { name, exact: true }).click(), - ]) - const id = await page.locator('#history tr').first().getAttribute('data-ceremony-id') + await expectReady(page, READY) + // Watched from the moment it appears: a platform may return it at once. + context.on('page', watchReturn) + const popup = await launch(page, name) + const row = newestRun(page) + const id = await row.getAttribute('data-ceremony-id') expect(id).toBeTruthy() + // The driver stops with the app's reason once the run fails. + watchAppFailure(popup, async () => { + const status = await page.evaluate((id) => window.results.get(id)?.status, id!) + if (!status || status === 'accepted') return undefined + return (await row.locator('.run-status').textContent()) || status + }) await authorize(popup) await expect @@ -92,9 +96,7 @@ async function proveThroughTheUi( } }, id!) // A failed run's row says why. - const message = await page - .locator(`#history tr[data-ceremony-id="${id}"] .run-status`) - .textContent() + const message = await row.locator('.run-status').textContent() expect(outcome.status, message ?? undefined).toBe('accepted') expect(outcome.platformId).toBe(platformOf[name]) // The account id as the platform sent it: GitHub's and X's are decimal, and diff --git a/ts/apps/dev/e2e/popup.ts b/ts/apps/dev/e2e/popup.ts index eca65c32..e89542db 100644 --- a/ts/apps/dev/e2e/popup.ts +++ b/ts/apps/dev/e2e/popup.ts @@ -49,6 +49,15 @@ export function watchReturn(popup: Page): { returned: boolean } { return watch } +const failures = new WeakMap Promise>() + +/** + * Let `drivePopup` stop early when the app's run for `popup` failed: `check` + * resolves to the app's reason, or undefined while the run goes on. + */ +export const watchAppFailure = (popup: Page, check: () => Promise) => + failures.set(popup, check) + /** * 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 @@ -68,6 +77,9 @@ export async function drivePopup(popup: Page, driver: Driver) { } while (Date.now() < deadline) { if (returned()) return + const failed = await failures.get(popup)?.() + if (failed) + throw new Error(`The app's run failed during the ${driver.name} authorization: ${failed}`) if (popup.isClosed()) throw new Error(`The popup closed before ${driver.name} returned it to Bridge`) const url = URL.parse(popup.url()) diff --git a/ts/apps/dev/src/app.fixtures.ts b/ts/apps/dev/src/app.fixtures.ts index 7bfb1655..f4719d97 100644 --- a/ts/apps/dev/src/app.fixtures.ts +++ b/ts/apps/dev/src/app.fixtures.ts @@ -77,9 +77,13 @@ export async function openApp( await serveBridge(page, platforms) await serveVersions(page, versions) await page.goto('/') - await expect(page.locator('#status')).toContainText('Ready.') + await expectReady(page) } +/** Waits for the app to report itself ready to launch, up to `timeout` ms. */ +export const expectReady = (page: Page, timeout?: number) => + expect(page.locator('#status')).toContainText('Ready.', { timeout }) + export const launchButton = (page: Page, name: string) => page.getByRole('button', { name, exact: true }) export const launchButtons = (page: Page) => page.locator('#platforms').getByRole('button') From 814d8d76ff0f03b1998bda1b61ed511e8fa06cc5 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 08:41:19 +0300 Subject: [PATCH 083/103] fix(dev): check sessions and the TOTP key before the stack starts - preflight() runs in the runner process at config load: the settings, then under LIBID_REQUIRE_LIVE each selected platform's saved session and the GitHub TOTP key, so a bad one fails in seconds. - With no platform selected the config starts no webServer. - With LIBID_STACK_LOGS the webServer appends the build's output to it too, so under CI a failed build is in the scanned, uploaded log rather than lost with the ignored stdout. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/preflight.ts | 17 ++++++++++++ ts/apps/dev/playwright.live.config.ts | 38 +++++++++++++++------------ 2 files changed, 38 insertions(+), 17 deletions(-) create mode 100644 ts/apps/dev/e2e/preflight.ts diff --git a/ts/apps/dev/e2e/preflight.ts b/ts/apps/dev/e2e/preflight.ts new file mode 100644 index 00000000..101498db --- /dev/null +++ b/ts/apps/dev/e2e/preflight.ts @@ -0,0 +1,17 @@ +// What a live run checks before its stack starts, in the runner process. +import { totp } from './github.ts' +import { googleSession, xSession } from './session.ts' +import { checkSettings, githubAccount, tooling } from './settings.ts' + +/** + * The settings, then under LIBID_REQUIRE_LIVE each selected platform's saved + * session and TOTP key: a bad one fails in seconds, not after the stack. + */ +export function preflight() { + checkSettings() + const { requireLive, platforms } = tooling() + if (!requireLive) return + if (platforms.includes('github')) totp(githubAccount()!.totpSecret) + if (platforms.includes('x')) xSession() + if (platforms.includes('google')) googleSession() +} diff --git a/ts/apps/dev/playwright.live.config.ts b/ts/apps/dev/playwright.live.config.ts index ea47deaf..6cd0fd9a 100644 --- a/ts/apps/dev/playwright.live.config.ts +++ b/ts/apps/dev/playwright.live.config.ts @@ -1,6 +1,7 @@ import { defineConfig, devices } from '@playwright/test' import { ACTION, NAVIGATION, STACK_START, STACK_STOP, TEST } from './e2e/budgets.ts' -import { checkSettings, envFile, loadEnvFile, tooling, unknownKeys } from './e2e/settings.ts' +import { preflight } from './e2e/preflight.ts' +import { envFile, loadEnvFile, tooling, unknownKeys } from './e2e/settings.ts' import { APP_ORIGIN } from './local.ts' // Test secrets from a dotenv file when LIBID_TEST_ENV_FILE names one; values @@ -13,8 +14,8 @@ if (file) { if (runner && typos.length) console.warn(`${file}: no live test reads ${typos.join(', ')}; check the spelling.`) } -if (runner) checkSettings() -const { proxy, headed, ci, stackLogs } = tooling() +if (runner) preflight() +const { proxy, headed, ci, stackLogs, platforms } = tooling() // The services' output carries what the job log must not: CI writes it to a // file it scans before upload. if (ci && !stackLogs) @@ -30,20 +31,23 @@ export default defineConfig({ reporter: [['list']], // One command: start the services and the app, test, then stop both. The // run never reuses a running stack: services.ts refuses taken ports. - webServer: { - // exec: Playwright's SIGTERM reaches services.ts, which brings the stack down. - command: 'pnpm build:deps && exec node services.ts --app', - ...(stackLogs && { - env: { ...(process.env as Record), LIBID_STACK_LOGS: stackLogs }, - }), - url: APP_ORIGIN, - reuseExistingServer: false, - timeout: STACK_START, - gracefulShutdown: { signal: 'SIGTERM', timeout: STACK_STOP }, - // Under CI the containers' output goes to LIBID_STACK_LOGS; the build's - // and services.ts' own messages stay on stderr. - stdout: ci ? 'ignore' : 'pipe', - }, + // No platform selected: nothing to start. + webServer: platforms.length + ? { + // exec: Playwright's SIGTERM reaches services.ts, which brings the stack + // down. With LIBID_STACK_LOGS, the build's output goes there too. + command: `pnpm build:deps${stackLogs ? ' >>"$LIBID_STACK_LOGS" 2>&1' : ''} && exec node services.ts --app`, + ...(stackLogs && { + env: { ...(process.env as Record), LIBID_STACK_LOGS: stackLogs }, + }), + url: APP_ORIGIN, + reuseExistingServer: false, + timeout: STACK_START, + gracefulShutdown: { signal: 'SIGTERM', timeout: STACK_STOP }, + // Under CI only services.ts' own messages reach the job log. + stdout: ci ? 'ignore' : 'pipe', + } + : undefined, use: { ...devices['Desktop Chrome'], baseURL: APP_ORIGIN, From ec82158364079c477b30d38a2ba4731a39546ff5 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 08:41:19 +0300 Subject: [PATCH 084/103] test(dev): check each live job's timeout covers its step and setup Each job's timeout-minutes must be at least its test step's plus a stated 10 minutes for checkout, install, the browser install, any proxy and the failure-output step. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/settings.test.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/ts/apps/dev/e2e/settings.test.ts b/ts/apps/dev/e2e/settings.test.ts index fda8e4eb..86064189 100644 --- a/ts/apps/dev/e2e/settings.test.ts +++ b/ts/apps/dev/e2e/settings.test.ts @@ -19,6 +19,12 @@ import { missing, type Need, PLATFORM_SECTIONS, SCANNED, VARIABLES } from './set const read = (relative: string) => readFileSync(new URL(relative, import.meta.url), 'utf8') const workflow = read('../../../../.github/workflows/live-e2e.yml') +/** + * Minutes a job needs beside its test step: checkout, install, the browser + * install, any proxy, and the failure-output step. + */ +const JOB_ALLOWANCE = 10 + /** The jobs that run live tests. */ const JOBS = ['ceremony', 'google-ssm'] @@ -36,13 +42,18 @@ function envEntry(line: string): [string, string] | undefined { const referenced = (text: string) => new Set([...text.matchAll(/\b(?:secrets|vars)\.([A-Za-z0-9_]+)/g)].map((match) => match[1]!)) -/** The lines of job `name`'s step holding the line `marker`. */ -function step(name: string, marker: string): string[] { +/** The lines of job `name`. */ +function jobLines(name: string): string[] { const lines = workflow.split('\njobs:\n')[1]!.split('\n') const start = lines.indexOf(` ${name}:`) assert.notEqual(start, -1, `the workflow has a job ${name}`) const end = lines.findIndex((line, index) => index > start && /^ {2}[a-z][\w-]*:$/.test(line)) - const job = lines.slice(start, end === -1 ? undefined : end) + return lines.slice(start, end === -1 ? undefined : end) +} + +/** The lines of job `name`'s step holding the line `marker`. */ +function step(name: string, marker: string): string[] { + const job = jobLines(name) const at = job.findIndex((line) => line.trim() === marker) assert.notEqual(at, -1, `job ${name} has a step with ${marker}`) const isStep = (line: string) => line.startsWith(' - ') @@ -111,7 +122,7 @@ test("each job's failure-output step is given every scanned secret its test step } }) -test('each test step outlasts its worst case: the stack, every platform it can select', () => { +test('each test step outlasts its worst case, and each job its step and setup', () => { const minutes = (ms: number) => ms / 60_000 const sum = NAVIGATION + READY + ACTION + DRIVER + OUTCOME assert.ok( @@ -125,6 +136,11 @@ test('each test step outlasts its worst case: the stack, every platform it can s STACK_START + selectable(entriesOf(step), name).length * TEST + STACK_STOP, ) assert.ok(timeout >= worst, `job ${name}'s test step allows ${timeout} min of ${worst}`) + const job = Number(/^ {4}timeout-minutes:\s*(\d+)/m.exec(jobLines(name).join('\n'))?.[1]) + assert.ok( + job >= timeout + JOB_ALLOWANCE, + `job ${name} allows ${job} min: its test step's ${timeout} and ${JOB_ALLOWANCE} for the rest`, + ) } }) From 411fc086fded2003adfe9ed65bd17150d3c959e5 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 08:41:19 +0300 Subject: [PATCH 085/103] ci(live-e2e): serialise the sign-in jobs; narrow the pull_request paths - Both live jobs share a job-level concurrency group across all runs (live-e2e-accounts, never cancelled): overlapping runs could submit the same TOTP code within one 30-second window. Pull request runs still cancel their own older run. GitHub keeps one pending job per group, so a newer pending job replaces an older pending one; the comment says so. - Pull requests trigger the workflow only for what a live ceremony runs: the dev app, the ceremony, popup and ledger packages, their build script and brand/ styles, the workspace setup, and the workflow and its two actions. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 0700542c..68e62b99 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -9,8 +9,20 @@ name: Live end-to-end on: pull_request: + # What a live ceremony runs: the dev app, the packages it bundles and + # their build (brand/ styles the ceremony), the workspace setup, and CI. paths: - - "ts/**" + - "ts/apps/dev/**" + - "ts/packages/ceremony/**" + - "ts/packages/popup/**" + - "ts/packages/ledger/**" + - "ts/scripts/build-package.ts" + - "ts/package.json" + - "ts/pnpm-lock.yaml" + - "ts/pnpm-workspace.yaml" + - "ts/.node-version" + - "ts/tsconfig.base.json" + - "brand/**" - ".github/workflows/live-e2e.yml" - ".github/actions/live-e2e-output/**" - ".github/actions/setup-ts/**" @@ -18,10 +30,12 @@ on: schedule: - cron: "23 3 * * *" -# 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 has its own notary and Bridge, and a rate-limited login backs off. +# One run per pull request, a newer push cancelling its older run; +# scheduled and dispatched runs share a group and are not cancelled. The +# jobs that sign in also share one group across every run (live-e2e-accounts), +# so no two runs use the test accounts at once: two sign-ins could submit +# the same TOTP code in one 30-second window. GitHub keeps one job pending +# per group, so a newer pending job replaces an older pending one. concurrency: 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' }} @@ -44,6 +58,9 @@ jobs: (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') runs-on: ubuntu-latest + concurrency: + group: live-e2e-accounts + cancel-in-progress: false # The test step's budget plus setup, the secret scan and the upload. timeout-minutes: 75 env: @@ -115,6 +132,9 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]')) runs-on: ubuntu-latest + concurrency: + group: live-e2e-accounts + cancel-in-progress: false # The test step's budget plus setup, the proxy, the secret scan and the upload. timeout-minutes: 50 permissions: From 44dedd1808667bea17ff590e3d81cd6690259b3c Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 08:41:19 +0300 Subject: [PATCH 086/103] docs(dev): state the path rule, preflight and CI log rows Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/README.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/ts/apps/dev/README.md b/ts/apps/dev/README.md index 881f9244..d879002a 100644 --- a/ts/apps/dev/README.md +++ b/ts/apps/dev/README.md @@ -119,12 +119,13 @@ LIBID_TEST_ENV_FILE=path/to/.env.test pnpm --filter @libid/dev test:live The variables, read through [e2e/settings.ts](e2e/settings.ts), carry the names libid-server-rs' ceremony-tests uses, so one `.env.test` serves both suites. With `LIBID_REQUIRE_LIVE`, the run checks every selected platform's -variables at once and names each one absent; without it, a test lacking its -variables is skipped. A switch is on for `1` or `true`, in any case. Keep the env file outside -the repository, or name it `.env.test`, which git ignores. A relative -`*_COOKIES_FILE` path is relative to the env file, or, without one, to where -you run pnpm. Keys in the env file that look like settings but that -neither suite reads are reported as likely typos. +variables at once, then reads each selected platform's saved session and +TOTP key, before the stack starts; without it, a test lacking its variables +is skipped. A switch is on for `1` or `true`, in any case. Keep the env file +outside the repository, or name it `.env.test`, which git ignores. The path +rule: a relative path in a setting is relative to the env file, or, without +one, to where you run pnpm. Keys in the env file that look like settings but +that neither suite reads are reported as likely typos. | Variable | Section | Need | Use | |---|---|---|---| @@ -133,14 +134,14 @@ neither suite reads are reported as likely typos. | `GH_TEST_ALICE_TOTP_SECRET` | github-account | required | The base32 key of its authenticator app. | | `X_TEST_ALICE_USERNAME` | x-account | required | The X test account's handle. | | `X_TEST_ALICE_COOKIES` | x-session | required | Its saved session, base64 of the JSON cookie list. | -| `X_TEST_ALICE_COOKIES_FILE` | x-session | instead of `X_TEST_ALICE_COOKIES` | A path to that JSON, relative to the env file. | +| `X_TEST_ALICE_COOKIES_FILE` | x-session | instead of `X_TEST_ALICE_COOKIES` | A path to that JSON (see the path rule above). | | `GOOGLE_TEST_ALICE_EMAIL` | google-account | required | The Google test account. | | `GOOGLE_TEST_ALICE_COOKIES` | google-session | required | Its saved session, as for X. | | `GOOGLE_TEST_ALICE_COOKIES_FILE` | google-session | instead of `GOOGLE_TEST_ALICE_COOKIES` | A path to that JSON, as for X. | -| `LIBID_TEST_ENV_FILE` | tooling | optional | A dotenv file with the variables above, relative to where you run pnpm; exported values win. | -| `LIBID_LIVE_PLATFORMS` | tooling | optional | Comma-separated platforms to prove, such as `github,x`; all three when unset, none when set empty. | +| `LIBID_TEST_ENV_FILE` | tooling | optional | A dotenv file with the variables above, relative to where you run pnpm; exported values win, and an exported empty one counts as unset except for `LIBID_LIVE_PLATFORMS`. | +| `LIBID_LIVE_PLATFORMS` | tooling | optional | Comma-separated platforms to prove, such as `github,x`; all three when unset, none when set empty. In CI the repository variable selects; unset or empty there means `github,x`. | | `LIBID_REQUIRE_LIVE` | tooling | optional | `1` or `true`: a missing variable or an empty platform list fails instead of skipping. | | `LIBID_TEST_PROXY` | tooling | optional | A proxy for the browser's traffic, loopback aside. | -| `LIBID_STACK_LOGS` | tooling | optional | A file the services' output, containers included, is appended to instead of the terminal; a relative path as for `*_COOKIES_FILE`. Required under CI. | +| `LIBID_STACK_LOGS` | tooling | optional | A file the build's and the services' output, containers included, is appended to instead of the terminal; a relative path follows the path rule above, in `pnpm dev` too. Required under CI. | | `HEADED` | tooling | optional | `1` or `true`: show the browser. | -| `CI` | tooling | optional | `1` or `true`, as CI services set it: no traces or videos; the containers' output goes only to `LIBID_STACK_LOGS`, and only the build's and services.ts' own messages reach the job log. | +| `CI` | tooling | optional | `1` or `true`, as CI services set it: no traces or videos; the build's and the containers' output go only to `LIBID_STACK_LOGS`, and only services.ts' own messages reach the job log. | From a91033d0be95939d92b143811c2b33015baccb46 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 09:09:20 +0300 Subject: [PATCH 087/103] fix(dev): keep the test secrets from the stack; start it only for a test - The live webServer's environment sets every account and session variable (settings.ts' table, outside tooling) to empty: services.ts, the build and the containers get none of the test accounts' secrets. The secret scan stays as a backstop. - The stack starts only when a test will run: a selected platform whose settings are all present (runnablePlatforms). A run that skips every test neither builds nor starts it. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/settings.ts | 11 +++++++++++ ts/apps/dev/playwright.live.config.ts | 24 +++++++++++++++++------- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/ts/apps/dev/e2e/settings.ts b/ts/apps/dev/e2e/settings.ts index 319a0712..d93e2dc2 100644 --- a/ts/apps/dev/e2e/settings.ts +++ b/ts/apps/dev/e2e/settings.ts @@ -119,6 +119,17 @@ export function tooling(get: Lookup = environment): Tooling { } } +/** The selected platforms whose settings are all present: the ones whose test runs. */ +export const runnablePlatforms = (get: Lookup = environment) => + tooling(get).platforms.filter((platform) => + PLATFORM_SECTIONS[platform].every((section) => !missing(section, get).length), + ) + +/** The accounts' and sessions' variables: every one outside the tooling section. */ +export const ACCOUNT_VARIABLES = VARIABLES.filter(([, section]) => section !== 'tooling').map( + ([name]) => name, +) + /** Whether this run proves `platform`; one left out is skipped, and reported as skipped. */ export const isLive = (platform: PlatformId) => tooling().platforms.includes(platform) diff --git a/ts/apps/dev/playwright.live.config.ts b/ts/apps/dev/playwright.live.config.ts index 6cd0fd9a..062f3faa 100644 --- a/ts/apps/dev/playwright.live.config.ts +++ b/ts/apps/dev/playwright.live.config.ts @@ -1,7 +1,14 @@ import { defineConfig, devices } from '@playwright/test' import { ACTION, NAVIGATION, STACK_START, STACK_STOP, TEST } from './e2e/budgets.ts' import { preflight } from './e2e/preflight.ts' -import { envFile, loadEnvFile, tooling, unknownKeys } from './e2e/settings.ts' +import { + ACCOUNT_VARIABLES, + envFile, + loadEnvFile, + runnablePlatforms, + tooling, + unknownKeys, +} from './e2e/settings.ts' import { APP_ORIGIN } from './local.ts' // Test secrets from a dotenv file when LIBID_TEST_ENV_FILE names one; values @@ -15,7 +22,7 @@ if (file) { console.warn(`${file}: no live test reads ${typos.join(', ')}; check the spelling.`) } if (runner) preflight() -const { proxy, headed, ci, stackLogs, platforms } = tooling() +const { proxy, headed, ci, stackLogs } = tooling() // The services' output carries what the job log must not: CI writes it to a // file it scans before upload. if (ci && !stackLogs) @@ -31,15 +38,18 @@ export default defineConfig({ reporter: [['list']], // One command: start the services and the app, test, then stop both. The // run never reuses a running stack: services.ts refuses taken ports. - // No platform selected: nothing to start. - webServer: platforms.length + // No test that will run: nothing to start. + webServer: runnablePlatforms().length ? { // exec: Playwright's SIGTERM reaches services.ts, which brings the stack // down. With LIBID_STACK_LOGS, the build's output goes there too. command: `pnpm build:deps${stackLogs ? ' >>"$LIBID_STACK_LOGS" 2>&1' : ''} && exec node services.ts --app`, - ...(stackLogs && { - env: { ...(process.env as Record), LIBID_STACK_LOGS: stackLogs }, - }), + // The stack gets none of the test accounts' secrets; the scan stays a backstop. + env: { + ...(process.env as Record), + ...Object.fromEntries(ACCOUNT_VARIABLES.map((name) => [name, ''])), + ...(stackLogs && { LIBID_STACK_LOGS: stackLogs }), + }, url: APP_ORIGIN, reuseExistingServer: false, timeout: STACK_START, From 4eaad1f4d503542cd0f932326e95b4915846ac96 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 09:09:20 +0300 Subject: [PATCH 088/103] fix(dev): fail the scan closed on an unreadable session file A *_COOKIES_FILE the scan cannot read now fails it (exit 4), naming the variable but not the path or anything in it, instead of being skipped. The scan also loads the file LIBID_TEST_ENV_FILE names, as the config does, so a session file set there is scanned and resolved by the same path rule. Both have tests. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/scan-secrets.test.ts | 18 ++++++++++++++++++ ts/apps/dev/e2e/scan-secrets.ts | 28 +++++++++++++++++----------- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/ts/apps/dev/e2e/scan-secrets.test.ts b/ts/apps/dev/e2e/scan-secrets.test.ts index 88e1a1f7..51bc0919 100644 --- a/ts/apps/dev/e2e/scan-secrets.test.ts +++ b/ts/apps/dev/e2e/scan-secrets.test.ts @@ -11,6 +11,7 @@ import { SCANNED } from './settings.ts' const SCRIPT = fileURLToPath(new URL('./scan-secrets.ts', import.meta.url)) const FOUND = 2 const NOTHING_TO_LOOK_FOR = 3 +const UNREADABLE = 4 const directory = mkdtempSync(join(tmpdir(), 'scan-secrets-')) after(() => rmSync(directory, { recursive: true, force: true })) const base64 = (text: string) => Buffer.from(text).toString('base64') @@ -90,3 +91,20 @@ test('under CI, a scan with no secret to look for fails', () => { assert.equal(scan('clean', { CI: 'true' }), NOTHING_TO_LOOK_FOR) assert.equal(scan('clean', {}), 0) }) + +test('a session file the scan cannot read fails it, naming the variable only', () => { + const missingFile = join(directory, 'no-such-session.json') + const run = spawnSync(process.execPath, [SCRIPT, file('clean')], { + env: { PATH: process.env.PATH, X_TEST_ALICE_COOKIES_FILE: missingFile }, + encoding: 'utf8', + }) + assert.equal(run.status, UNREADABLE) + assert.match(run.stderr, /X_TEST_ALICE_COOKIES_FILE/) + assert.doesNotMatch(run.stderr, /no-such-session/) +}) + +test('settings in the env file are scanned too, its relative paths beside it', () => { + const sessionFile = file(cookies('ENVFILE-MARKER-1')) + const envFile = file(`X_TEST_ALICE_COOKIES_FILE=${sessionFile.split('/').pop()}\n`) + assert.equal(scan('x ENVFILE-MARKER-1 y', { LIBID_TEST_ENV_FILE: envFile }), FOUND) +}) diff --git a/ts/apps/dev/e2e/scan-secrets.ts b/ts/apps/dev/e2e/scan-secrets.ts index eff6e36c..2091568e 100644 --- a/ts/apps/dev/e2e/scan-secrets.ts +++ b/ts/apps/dev/e2e/scan-secrets.ts @@ -3,12 +3,17 @@ // every value it holds (its cookies' and, for a storage state, its origins' // localStorage values), each also URL-encoded and JSON-escaped. A session // that does not decode is still looked for as set. Paths are the arguments; a -// missing file is skipped. Exit 2: a secret was found; 3: under CI, there was -// none to look for. Nothing secret is printed. +// missing file is skipped. Settings come from the environment and the file +// LIBID_TEST_ENV_FILE names, as settings.ts reads them. Exit 2: a secret was +// found; 3: under CI, there was none to look for; 4: a *_COOKIES_FILE could +// not be read. Nothing secret is printed. import { existsSync, readFileSync } from 'node:fs' import { settingPath } from '../paths.ts' import { exportCookies } from './session.ts' -import { SCANNED, tooling } from './settings.ts' +import { envFile, loadEnvFile, SCANNED, tooling } from './settings.ts' + +const settingsFile = envFile() +if (settingsFile) loadEnvFile(settingsFile) const environment = (name: string) => process.env[name] @@ -45,16 +50,17 @@ function heldValues(text: string): unknown[] { function secretsOf(name: string): unknown[] { const value = environment(name) if (!value) return [] - let text: string - try { - if (name.endsWith('_COOKIES_FILE')) + if (name.endsWith('_COOKIES_FILE')) { + // A session file the scan cannot read could hold anything: fail closed. + try { return heldValues(readFileSync(settingPath(environment, value)!, 'utf8')) - if (!name.endsWith('_COOKIES')) return [value] - text = Buffer.from(value.trim(), 'base64').toString('utf8') - } catch { - return [] + } catch { + console.error(`Cannot read the file ${name} names, so cannot scan for its values`) + process.exit(4) + } } - return [value.trim(), ...heldValues(text)] + if (!name.endsWith('_COOKIES')) return [value] + return [value.trim(), ...heldValues(Buffer.from(value.trim(), 'base64').toString('utf8'))] } /** A secret as written, URL-encoded and JSON-escaped. */ From 2f6db1cc1d486e60cfa09baeaab48a9055cb02dd Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 09:09:20 +0300 Subject: [PATCH 089/103] ci(live-e2e): best-effort stack cleanup; its project from services.ts - The failure-output action stops the stack best effort: a failed `docker compose down` no longer aborts the step before the scan and the upload. The scan's failure stays fatal. - `node services.ts --project` prints the checkout's Compose project, which the action stops, instead of repeating its libid-dev prefix. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/actions/live-e2e-output/action.yml | 9 +++++---- ts/apps/dev/services.ts | 5 +++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/actions/live-e2e-output/action.yml b/.github/actions/live-e2e-output/action.yml index 4093b59c..5afadf37 100644 --- a/.github/actions/live-e2e-output/action.yml +++ b/.github/actions/live-e2e-output/action.yml @@ -17,13 +17,14 @@ runs: using: composite steps: # 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. A secret - # in it fails this step, and nothing goes up. + # the log after it is scanned; what goes up is the copy scanned. Stopping + # it is best effort; a secret in the log, or a scan that cannot run, + # fails this step, and nothing goes up. - name: Check the stack log for secrets shell: bash run: | - docker compose ls -q --all --filter name=libid-dev | - xargs -r -I{} docker compose -p {} down --volumes + project=$(node ts/apps/dev/services.ts --project) && + docker compose -p "$project" down --volumes || true if [ -f "$LIBID_STACK_LOGS" ]; then cp "$LIBID_STACK_LOGS" "$RUNNER_TEMP/stack-scanned.log"; fi node ts/apps/dev/e2e/scan-secrets.ts "$RUNNER_TEMP/stack-scanned.log" diff --git a/ts/apps/dev/services.ts b/ts/apps/dev/services.ts index 3b2e4295..07f0fab6 100644 --- a/ts/apps/dev/services.ts +++ b/ts/apps/dev/services.ts @@ -16,6 +16,11 @@ const root = fileURLToPath(new URL('.', import.meta.url)) // Distinct Compose ownership for each checkout. No shared container names. const project = `libid-dev-${createHash('sha256').update(root).digest('hex').slice(0, 12)}` const composeArgs = ['compose', '-p', project, '-f', join(root, 'compose.yaml')] +// --project: print this checkout's Compose project, for a cleanup to stop it. +if (process.argv.includes('--project')) { + console.info(project) + process.exit(0) +} const logPath = settingPath((name) => process.env[name], process.env.LIBID_STACK_LOGS) const logs = logPath ? openSync(logPath, 'a') : undefined const stdio = From 7d403d9df7e507cf447a1f3df6836eb9018e9de2 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 09:09:20 +0300 Subject: [PATCH 090/103] fix(dev): refuse to start services when the app's port is taken With --app, services.ts also probes APP_ORIGIN's port before starting, beside compose.yaml's published ports. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/services.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ts/apps/dev/services.ts b/ts/apps/dev/services.ts index 07f0fab6..17e214f4 100644 --- a/ts/apps/dev/services.ts +++ b/ts/apps/dev/services.ts @@ -66,6 +66,8 @@ if (!process.argv.includes('--ccdp')) { process.exit(1) } } + // The app's own port, when this process serves it. + if (process.argv.includes('--app')) ports.push(Number(new URL(APP_ORIGIN).port)) const open = await Promise.all(ports.map(listening)) const taken = ports.filter((_, index) => open[index]) if (taken.length && own) { From e2d55acb7711f98e9089226607e5f5248ae8defc Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 09:09:20 +0300 Subject: [PATCH 091/103] fix(dev): a saved Google session needs the sign-in google.rs checks A saved Google session holds a session only with LSID or __Host-1PLSID on accounts.google.com, as libid-server-rs' google.rs signed_in has it, rather than SID or __Secure-*PSID; preflight checks it the same way. The X rule stays x.rs' signed_in: an auth_token. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/session.ts | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/ts/apps/dev/e2e/session.ts b/ts/apps/dev/e2e/session.ts index c761ac55..f3ef6817 100644 --- a/ts/apps/dev/e2e/session.ts +++ b/ts/apps/dev/e2e/session.ts @@ -128,21 +128,22 @@ export function parseExport( /** * The platform's cookies of a saved session setting, or undefined when it is - * not set. `required` names cookies of which at least one must be present for - * the export to hold a session. + * not set. `signedIn` says, as the Rust suite's signed_in does, whether the + * cookies hold a session; `needs` names what it looks for. */ function cookiesOf( saved: SavedSession | undefined, label: string, keep: (host: string) => boolean, - required: string[], + signedIn: (cookie: Stored) => boolean, + needs: string, ): Stored[] | undefined { if (!saved) return undefined const cookies = parseExport(exportText(saved), keep) if (!cookies) throw new Error(`The saved ${label} session is not a JSON cookie list or { cookies }`) - if (!cookies.some((cookie) => required.includes(cookie.name))) - throw new Error(`The saved ${label} session has none of ${required.join(', ')} unexpired`) + if (!cookies.some(signedIn)) + throw new Error(`The saved ${label} session has no unexpired ${needs}`) return cookies } @@ -152,7 +153,14 @@ function cookiesOf( * ceremony-tests, which lives on its feat/live-ceremony-tests branch * (libid-server-rs PR #12). */ -export const xSession = () => cookiesOf(settings.xSession(), 'X_TEST_ALICE', onX, ['auth_token']) +export const xSession = () => + cookiesOf( + settings.xSession(), + 'X_TEST_ALICE', + onX, + (cookie) => cookie.name === 'auth_token', + 'auth_token', + ) /** * The saved Google session, from GOOGLE_TEST_ALICE_COOKIES or @@ -162,11 +170,16 @@ export const xSession = () => cookiesOf(settings.xSession(), 'X_TEST_ALICE', onX * sign-in in an automated browser, but honours a session made elsewhere. */ export const googleSession = () => - cookiesOf(settings.googleSession(), 'GOOGLE_TEST_ALICE', onGoogle, [ - 'SID', - '__Secure-1PSID', - '__Secure-3PSID', - ]) + cookiesOf( + settings.googleSession(), + 'GOOGLE_TEST_ALICE', + onGoogle, + // A sign-in accounts.google.com honours (google.rs signed_in). + (cookie) => + cookie.domain.endsWith('accounts.google.com') && + (cookie.name === 'LSID' || cookie.name === '__Host-1PLSID'), + 'LSID or __Host-1PLSID on accounts.google.com', + ) const SAME_SITE = new Map([ ['strict', 'Strict'], From db521151b1d02b2755fca46c9f3aac90a1c1821a Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 09:09:20 +0300 Subject: [PATCH 092/103] fix(dev): fail fast on X's error page and a held account As the Rust driver does, "Something went wrong" on a page other than the consent, login or /account/access ones fails the run, naming the path. X holding the account at /account/access for more than 30 s, the Rust suite's wait, fails with a named error. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/x.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/ts/apps/dev/e2e/x.ts b/ts/apps/dev/e2e/x.ts index 25ff62e9..401c6bd4 100644 --- a/ts/apps/dev/e2e/x.ts +++ b/ts/apps/dev/e2e/x.ts @@ -26,6 +26,9 @@ const FIND_CONSENT = `[...document.querySelectorAll("[data-testid='OAuth_Consent (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)` +/** How long X may hold the account at /account/access, as the Rust suite waits. */ +const ACCOUNT_ACCESS_GRACE = 30_000 + /** * Approve the OAuth app on X in `popup`, signed in by the saved session, until * X returns it to Bridge's callback. A port of libid-server-rs' ceremony-tests/src/browser/x.rs @@ -35,6 +38,7 @@ const FIND_CONSENT = `[...document.querySelectorAll("[data-testid='OAuth_Consent export async function authorizeOnX(popup: Page) { let consentedAt = 0 let challengedAt: number | undefined + let heldSince: number | undefined await drivePopup(popup, { name: 'X', onHost: (url) => onX(url.hostname), @@ -52,7 +56,15 @@ export async function authorizeOnX(popup: Page) { await evaluateWithin(popup, ACCEPT_COOKIES, false) if (Date.now() - consentedAt > 10_000 && (await clickFound(popup, FIND_CONSENT))) consentedAt = Date.now() - } + } else if (url.pathname.startsWith('/account/access')) { + // X holding the account: locked, or asking a person to verify it. + heldSince ??= Date.now() + if (Date.now() - heldSince > ACCOUNT_ACCESS_GRACE) + throw new Error( + 'X holds the account at /account/access (locked or asking for verification): clear it in a browser, then renew the saved session', + ) + } else if (text.includes('something went wrong')) + throw new Error(`X answered an error page on ${url.pathname}`) }, }) } From 76771ebb4131c8360e17f8ee6ca582e22145617a Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 09:09:20 +0300 Subject: [PATCH 093/103] fix(dev): keep GitHub's TOTP refusal reason; submit each code once - When the code's POST target, /sessions/two-factor, shows a failure, the driver keeps GitHub's flash message before going back to the code form, and the refusal error carries it. - After filling six digits the driver waits up to 3 s for GitHub's own submit and presses the button only if the form is still there, so one code is not sent twice. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/github.ts | 47 ++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/ts/apps/dev/e2e/github.ts b/ts/apps/dev/e2e/github.ts index 786cb882..11720ef9 100644 --- a/ts/apps/dev/e2e/github.ts +++ b/ts/apps/dev/e2e/github.ts @@ -26,6 +26,16 @@ export function totp(secret: string, time = Date.now()): string { }) } +/** GitHub's flash message on the page, whitespace collapsed, up to 200 characters. */ +async function flashText(popup: Page): Promise { + const flash = await popup + .locator('.flash-error, .flash-warn, #js-flash-container') + .first() + .innerText({ timeout: 1_000 }) + .catch(() => '') + return flash.split(/\s+/).join(' ').trim().slice(0, 200) +} + /** 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. */ @@ -36,6 +46,8 @@ interface Progress { totpStep?: number totpAttempts: number totpSubmittedAt?: number + /** GitHub's message for the last refused code. */ + totpRefusal?: string /** When the authorize button was last clicked; it is not clicked again within 5 s. */ authorizedAt: number /** When the 2FA checkup was last skipped; it is not skipped again within 5 s. */ @@ -85,14 +97,8 @@ async function onLogin( // The form still showing well after a submit is GitHub refusing the sign-in. if (progress.filledAt !== undefined) { if (Date.now() - progress.filledAt < LOGIN_SETTLE) return - const flash = await popup - .locator('.flash-error, .flash-warn, #js-flash-container') - .first() - .innerText({ timeout: 1_000 }) - .catch(() => '') - throw new Error( - `GitHub showed the login form again after sign-in: ${flash.split(/\s+/).join(' ').trim().slice(0, 200) || 'no message'}`, - ) + const flash = await flashText(popup) + throw new Error(`GitHub showed the login form again after sign-in: ${flash || 'no message'}`) } // A step that fails is tried again on the next poll, inside drivePopup's budget. try { @@ -105,7 +111,7 @@ async function onLogin( progress.filledAt = Date.now() } -async function onTotp(popup: Page, account: GitHubAccount, progress: Progress, text: string) { +async function onTotp(popup: Page, account: GitHubAccount, progress: Progress) { // One clock read for the step and its code. const now = Date.now() const step = Math.floor(now / 30_000) @@ -115,7 +121,7 @@ async function onTotp(popup: Page, account: GitHubAccount, progress: Progress, t // The code form still showing, settled and in a later step: GitHub refused the code. if (progress.totpAttempts >= TOTP_ATTEMPTS) throw new Error( - `GitHub refused ${TOTP_ATTEMPTS} TOTP codes${text.includes('two-factor authentication failed') ? ' (two-factor authentication failed)' : ''}: check GH_TEST_ALICE_TOTP_SECRET and this host's clock`, + `GitHub refused ${TOTP_ATTEMPTS} TOTP codes${progress.totpRefusal ? ` (${progress.totpRefusal})` : ''}: check GH_TEST_ALICE_TOTP_SECRET and this host's clock`, ) const code = totp(account.totpSecret, now) try { @@ -123,11 +129,17 @@ async function onTotp(popup: Page, account: GitHubAccount, progress: Progress, t } catch { return } - // GitHub submits the code on its own once six digits are in. - await popup - .locator('button[type=submit]') - .click({ timeout: 2_000 }) - .catch(() => {}) + // GitHub submits the code on its own once six digits are in; the button is + // pressed only for a form still there after a moment, so a code goes once. + const submitted = await popup + .waitForURL((url) => url.pathname !== '/sessions/two-factor/app', { timeout: 3_000 }) + .then(() => true) + .catch(() => false) + if (!submitted) + await popup + .locator('button[type=submit]') + .click({ timeout: 2_000 }) + .catch(() => {}) progress.totpStep = step progress.totpSubmittedAt = Date.now() progress.totpAttempts++ @@ -173,8 +185,11 @@ async function onGitHubPage( 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, text) + if (path === '/sessions/two-factor/app') return onTotp(popup, account, progress) if (path.startsWith('/sessions/two-factor')) { + // The code's POST target answering a refusal: keep GitHub's reason first. + if (text.includes('failed')) + progress.totpRefusal = (await flashText(popup)) || 'two-factor authentication failed' await popup .goto('https://github.com/sessions/two-factor/app', { timeout: STEP_TIMEOUT * 2 }) .catch(() => {}) From b6e50f56adedc094ca234d7d30aa1c639f84cd68 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 09:09:20 +0300 Subject: [PATCH 094/103] fix(dev): bound the app-failure check; budget every bounded test step - drivePopup races the app-failure check, as well as each step, against its budget. - budgets.ts lists the bounded steps one test performs (page load, Ready, the launch click, the popup, the run's id, the driver, the outcome, the row's message): 15 minutes; TEST is 16. settings.test.ts requires TEST to exceed their sum, and the CI step timeouts' comments follow (60 and 28 minutes, within 65 and 35). Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 8 ++++---- ts/apps/dev/e2e/budgets.ts | 16 ++++++++++++++-- ts/apps/dev/e2e/popup.ts | 31 ++++++++++++++++++++----------- ts/apps/dev/e2e/settings.test.ts | 15 +++------------ 4 files changed, 41 insertions(+), 29 deletions(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 68e62b99..50380eb9 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -81,9 +81,9 @@ jobs: # session arriving from a runner's address ("Verify it's you"), which no # automation passes. The google-ssm job runs it through a proxy. # Its own timeout, so a run that hangs still reaches the step below: the - # stack's 10-minute start, up to three tests of 15 minutes (all the + # stack's 10-minute start, up to three tests of 16 minutes (all the # variable can select), and 2 minutes to stop the stack - # (playwright.live.config.ts): 57 minutes, with room to spare. + # (ts/apps/dev/e2e/budgets.ts): 60 minutes, with room to spare. - name: Prove identities end to end id: test timeout-minutes: 65 @@ -176,8 +176,8 @@ jobs: echo "Google sees ${ip:?the proxy did not answer; see $RUNNER_TEMP/port-forward.log}" # Its own timeout, so a run that hangs still reaches the step below: the - # stack's 10-minute start, one test of 15 minutes, and 2 minutes to stop - # the stack (playwright.live.config.ts): 27 minutes, with room to spare. + # stack's 10-minute start, one test of 16 minutes, and 2 minutes to stop + # the stack (ts/apps/dev/e2e/budgets.ts): 28 minutes, with room to spare. - name: Prove a Google identity end to end id: test timeout-minutes: 35 diff --git a/ts/apps/dev/e2e/budgets.ts b/ts/apps/dev/e2e/budgets.ts index da5096b4..4950d952 100644 --- a/ts/apps/dev/e2e/budgets.ts +++ b/ts/apps/dev/e2e/budgets.ts @@ -17,5 +17,17 @@ export const DRIVER = 180_000 /** Proving in the browser, after the platform returns the popup. */ export const OUTCOME = 8 * 60_000 -/** One test: the budgets above, 13.5 minutes, with margin. */ -export const TEST = 15 * 60_000 +/** The bounded steps one test performs, in order: 15 minutes in all. */ +export const TEST_STEPS = [ + NAVIGATION, // the app's page load + READY, + ACTION, // the launch click + ACTION, // the popup's appearance + ACTION, // the run's id from its row + DRIVER, + OUTCOME, + ACTION, // the row's message +] + +/** One test: its steps, with margin. */ +export const TEST = 16 * 60_000 diff --git a/ts/apps/dev/e2e/popup.ts b/ts/apps/dev/e2e/popup.ts index e89542db..8e58283f 100644 --- a/ts/apps/dev/e2e/popup.ts +++ b/ts/apps/dev/e2e/popup.ts @@ -49,6 +49,9 @@ export function watchReturn(popup: Page): { returned: boolean } { return watch } +/** What a step that outlasts the driver's budget resolves to. */ +const LATE: unique symbol = Symbol('late') + const failures = new WeakMap Promise>() /** @@ -75,9 +78,24 @@ export async function drivePopup(popup: Page, driver: Driver) { ) await sleep(ms) } + /** + * `work`'s value, or LATE once the budget runs out first: late work is left + * behind, its rejection handled. + */ + const withinBudget = async (work: Promise): Promise => { + work.catch(() => {}) + const budget = new AbortController() + return Promise.race([ + work, + sleep(Math.max(0, deadline - Date.now()), LATE, { signal: budget.signal }).catch( + (): typeof LATE => LATE, + ), + ]).finally(() => budget.abort()) + } while (Date.now() < deadline) { if (returned()) return - const failed = await failures.get(popup)?.() + const failed = await withinBudget(failures.get(popup)?.() ?? Promise.resolve(undefined)) + if (failed === LATE) break if (failed) throw new Error(`The app's run failed during the ${driver.name} authorization: ${failed}`) if (popup.isClosed()) @@ -92,16 +110,7 @@ export async function drivePopup(popup: Page, driver: Driver) { .catch(() => '') const text = visible.split(/\s+/).join(' ').trim().toLowerCase().replaceAll('’', "'") // A step that outlasts the budget is left behind: the error names the page. - const budget = new AbortController() - const step = driver.step(url, text, pause) - step.catch(() => {}) - const late = await Promise.race([ - step.then(() => false), - sleep(Math.max(0, deadline - Date.now()), true, { signal: budget.signal }).catch( - () => false, - ), - ]).finally(() => budget.abort()) - if (late) break + if ((await withinBudget(driver.step(url, text, pause))) === LATE) break } await sleep(250) } diff --git a/ts/apps/dev/e2e/settings.test.ts b/ts/apps/dev/e2e/settings.test.ts index 86064189..7e34f9df 100644 --- a/ts/apps/dev/e2e/settings.test.ts +++ b/ts/apps/dev/e2e/settings.test.ts @@ -4,16 +4,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { test } from 'node:test' import type { PlatformId } from '@libid/ceremony' -import { - ACTION, - DRIVER, - NAVIGATION, - OUTCOME, - READY, - STACK_START, - STACK_STOP, - TEST, -} from './budgets.ts' +import { STACK_START, STACK_STOP, TEST, TEST_STEPS } from './budgets.ts' import { missing, type Need, PLATFORM_SECTIONS, SCANNED, VARIABLES } from './settings.ts' const read = (relative: string) => readFileSync(new URL(relative, import.meta.url), 'utf8') @@ -124,9 +115,9 @@ test("each job's failure-output step is given every scanned secret its test step test('each test step outlasts its worst case, and each job its step and setup', () => { const minutes = (ms: number) => ms / 60_000 - const sum = NAVIGATION + READY + ACTION + DRIVER + OUTCOME + const sum = TEST_STEPS.reduce((total, step) => total + step, 0) assert.ok( - TEST >= sum, + TEST > sum, `the test timeout ${minutes(TEST)} min is under its budgets' ${minutes(sum)}`, ) for (const name of JOBS) { From f9a0086074aa4b08b9a0febb7dfad822c907a025 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 09:38:45 +0300 Subject: [PATCH 095/103] refactor(dev)!: simplify the live tests to their core The live tests had grown guards and drift checks around a small core. This keeps the core and drops the rest: - settings.ts: each platform's variables (the _COOKIES/_COOKIES_FILE alternates included), the env file, LIBID_LIVE_PLATFORMS checked, one "missing" error under LIBID_REQUIRE_LIVE, and the runnable platforms that decide whether the stack starts. - session.ts: decode a saved session without echoing the parser's message, keep the platform's hosts, check one signed-in cookie per platform (X auth_token; Google LSID or __Host-1PLSID on accounts.google.com), and map to addCookies. - The stack's environment is an allowlist (PATH, HOME, USER, TMPDIR, LANG, CI, PNPM_HOME, XDG_*, DOCKER_*; Playwright merges its own, so everything else is set empty): no test account's secret, the Rust suite's passwords included, reaches the build, services.ts or the containers. - github.ts: one TOTP per 30-second step, each code submitted once, the login filled once, the 2FA checkup skipped; the reference-vector test stays. - services.ts: LIBID_STACK_LOGS (appended) and the refusal to start on a taken port, compose's published ones and the app's. - The X and Google drivers are back to their first port; X pages are recognised with session.ts' onX. Removed: the secret scan and its tests, preflight, the shared path and budget modules, the settings/README/workflow drift tests, the typo warning, the notary health probe, services.ts --project and its compose-port checks, per-field cookie validation and the __Host- url branch. BREAKING CHANGE: LIBID_STACK_LOGS is no longer required under CI, and the scan-secrets script is gone. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/budgets.ts | 33 ---- ts/apps/dev/e2e/ceremony.live.spec.ts | 157 ++++++---------- ts/apps/dev/e2e/github.test.ts | 12 +- ts/apps/dev/e2e/github.ts | 216 +++++++--------------- ts/apps/dev/e2e/google.ts | 21 +-- ts/apps/dev/e2e/popup.ts | 80 ++------- ts/apps/dev/e2e/preflight.ts | 17 -- ts/apps/dev/e2e/scan-secrets.test.ts | 110 ------------ ts/apps/dev/e2e/scan-secrets.ts | 97 ---------- ts/apps/dev/e2e/session.test.ts | 179 +++---------------- ts/apps/dev/e2e/session.ts | 231 ++++++------------------ ts/apps/dev/e2e/settings.test.ts | 156 ---------------- ts/apps/dev/e2e/settings.ts | 248 +++++--------------------- ts/apps/dev/e2e/x.ts | 18 +- ts/apps/dev/local.test.ts | 25 --- ts/apps/dev/package.json | 2 +- ts/apps/dev/paths.ts | 26 --- ts/apps/dev/playwright.live.config.ts | 72 +++----- ts/apps/dev/services.ts | 78 ++------ 19 files changed, 312 insertions(+), 1466 deletions(-) delete mode 100644 ts/apps/dev/e2e/budgets.ts delete mode 100644 ts/apps/dev/e2e/preflight.ts delete mode 100644 ts/apps/dev/e2e/scan-secrets.test.ts delete mode 100644 ts/apps/dev/e2e/scan-secrets.ts delete mode 100644 ts/apps/dev/e2e/settings.test.ts delete mode 100644 ts/apps/dev/local.test.ts delete mode 100644 ts/apps/dev/paths.ts diff --git a/ts/apps/dev/e2e/budgets.ts b/ts/apps/dev/e2e/budgets.ts deleted file mode 100644 index 4950d952..00000000 --- a/ts/apps/dev/e2e/budgets.ts +++ /dev/null @@ -1,33 +0,0 @@ -// The live run's time budgets, in milliseconds: the config, the spec and the -// popup driver take them from here, and settings.test.ts checks the CI step -// timeouts against them. - -/** Starting the stack and the app (webServer). */ -export const STACK_START = 10 * 60_000 -/** Stopping them (webServer's graceful shutdown). */ -export const STACK_STOP = 2 * 60_000 -/** A page load. */ -export const NAVIGATION = 60_000 -/** One action, such as the click that opens the popup. */ -export const ACTION = 30_000 -/** The app's cold start to Ready. */ -export const READY = 60_000 -/** The popup driver, sign-in to the platform's return. */ -export const DRIVER = 180_000 -/** Proving in the browser, after the platform returns the popup. */ -export const OUTCOME = 8 * 60_000 - -/** The bounded steps one test performs, in order: 15 minutes in all. */ -export const TEST_STEPS = [ - NAVIGATION, // the app's page load - READY, - ACTION, // the launch click - ACTION, // the popup's appearance - ACTION, // the run's id from its row - DRIVER, - OUTCOME, - ACTION, // the row's message -] - -/** One test: its steps, with margin. */ -export const TEST = 16 * 60_000 diff --git a/ts/apps/dev/e2e/ceremony.live.spec.ts b/ts/apps/dev/e2e/ceremony.live.spec.ts index 1f763dc4..fcaea5a6 100644 --- a/ts/apps/dev/e2e/ceremony.live.spec.ts +++ b/ts/apps/dev/e2e/ceremony.live.spec.ts @@ -1,148 +1,109 @@ import type { PlatformId } from '@libid/ceremony' import { type BrowserContext, expect, type Page, test } from '@playwright/test' import { expectReady, launch, newestRun } from '../src/app.fixtures.ts' -import { OUTCOME, READY } from './budgets.ts' import { authorizeOnGitHub } from './github.ts' import { authorizeOnGoogle } from './google.ts' import { presentAsPerson } from './person.ts' import { watchAppFailure, watchReturn } from './popup.ts' import { googleSession, restoreSession, xSession } from './session.ts' -import { - githubAccount, - googleAccount, - isLive, - missing, - type Section, - xAccount, -} from './settings.ts' +import { env, livePlatforms, missing } from './settings.ts' import { authorizeOnX, restoreXSession } from './x.ts' -type Name = 'GitHub' | 'X' | 'Google' - -const platformOf: Record = { GitHub: 'github', X: 'x', Google: 'google' } - -/** The proof each platform's result carries: GitHub's and X's bearer link, Google's ZK proof. */ -const proofField: Record = { - GitHub: 'bearerLinkProof', - X: 'bearerLinkProof', - Google: 'identityProof', -} - -/** What the test reads of a run's entry in window.results. */ -interface Outcome { - status: string - platformId?: string - userId?: string - userName?: string - idNodeBytes?: number - handleNodeBytes?: number - /** The byte length of each Uint8Array field of the proof, by name. */ - proofBytes?: Record - ceremonyVersion?: string - expiresAt?: number -} +const PLATFORMS = { + GitHub: { id: 'github', proof: 'bearerLinkProof' }, + X: { id: 'x', proof: 'bearerLinkProof' }, + Google: { id: 'google', proof: 'identityProof' }, +} as const /** - * Start the platform's ceremony from its anchor, let `authorize` drive the - * popup through the platform, then check the run's result: accepted, the - * proved account with its id and handle nodes, the ceremony version, and a + * Start `name`'s ceremony from the app, let `authorize` drive the popup, then + * check the run's result: accepted, the proved account and its nodes, and a * non-empty, unexpired proof. */ async function proveThroughTheUi( page: Page, context: BrowserContext, - name: Name, - expectedUser: string, + name: keyof typeof PLATFORMS, + user: string, authorize: (popup: Page) => Promise, ) { await page.goto('/') - // A cold app loads the Bridge configuration and CCDP's version list first. - await expectReady(page, READY) - // Watched from the moment it appears: a platform may return it at once. + await expectReady(page, 60_000) + // Watched as it appears: a platform may return it at once. context.on('page', watchReturn) const popup = await launch(page, name) const row = newestRun(page) - const id = await row.getAttribute('data-ceremony-id') - expect(id).toBeTruthy() - // The driver stops with the app's reason once the run fails. + const id = (await row.getAttribute('data-ceremony-id'))! + const status = () => page.evaluate((id) => window.results.get(id)?.status, id) watchAppFailure(popup, async () => { - const status = await page.evaluate((id) => window.results.get(id)?.status, id!) - if (!status || status === 'accepted') return undefined - return (await row.locator('.run-status').textContent()) || status + const now = await status() + return now && now !== 'accepted' + ? (await row.locator('.run-status').textContent()) || now + : undefined }) await authorize(popup) - await expect - .poll(() => page.evaluate((id) => window.results.has(id), id!), { timeout: OUTCOME }) - .toBe(true) - const outcome = await page.evaluate((id): Outcome => { - const result = window.results.get(id)! - if (result.status !== 'accepted') return { status: result.status } + await expect.poll(status, { timeout: 8 * 60_000 }).toBeDefined() + expect(await status(), (await row.locator('.run-status').textContent()) ?? '').toBe('accepted') + const result = await page.evaluate((id) => { + const result = window.results.get(id) + if (result?.status !== 'accepted') return undefined const { identity, oauthProof } = result + const proof = oauthProof.proof as unknown as Record return { - status: result.status, platformId: identity.platformId, userId: identity.userId, - userName: identity.userName, - idNodeBytes: identity.idNode.length, - handleNodeBytes: identity.handleNode.length, + userName: identity.userName.toLowerCase(), + nodes: [identity.idNode.length, identity.handleNode.length], proofBytes: Object.fromEntries( - Object.entries(oauthProof.proof).flatMap(([field, value]) => - value instanceof Uint8Array ? [[field, value.length]] : [], - ), + Object.entries(proof).map(([field, value]) => [field, (value as Uint8Array)?.length]), ), - ceremonyVersion: String(oauthProof.platformCeremonyVersion), + version: oauthProof.platformCeremonyVersion, expiresAt: oauthProof.expiresAt, } - }, id!) - // A failed run's row says why. - const message = await row.locator('.run-status').textContent() - expect(outcome.status, message ?? undefined).toBe('accepted') - expect(outcome.platformId).toBe(platformOf[name]) - // The account id as the platform sent it: GitHub's and X's are decimal, and - // @libid/contracts' id rules admit any string bytes for Google's `sub`. - if (name === 'Google') expect(outcome.userId).not.toMatch(/^0x/) - else expect(outcome.userId).toMatch(/^\d+$/) - expect(outcome.userId).toBeTruthy() - expect(outcome.userName?.toLowerCase()).toBe(expectedUser.toLowerCase()) - expect(outcome.idNodeBytes).toBe(32) - expect(outcome.handleNodeBytes).toBe(32) - expect(outcome.proofBytes?.[proofField[name]]).toBeGreaterThan(0) - expect(outcome.ceremonyVersion).toMatch(/^[1-9]\d*$/) - expect(outcome.expiresAt).toBeGreaterThan(Date.now() / 1000) + }, id) + const { id: platform, proof } = PLATFORMS[name] + expect(result?.platformId).toBe(platform) + // The account id as sent: GitHub's and X's are decimal; Google's `sub`, any string. + expect(result?.userId).toMatch(name === 'Google' ? /^(?!0x)./ : /^\d+$/) + expect(result?.userName).toBe(user.toLowerCase()) + expect(result?.nodes).toEqual([32, 32]) + expect(result?.proofBytes[proof]).toBeGreaterThan(0) + expect(result?.version).toBeGreaterThan(0) + expect(result?.expiresAt).toBeGreaterThan(Date.now() / 1000) } -/** Why `section`'s test is skipped: the variables it lacks. */ -const lacking = (...sections: Section[]) => - `${sections.flatMap((s) => missing(s)).join(', ')} not set` +/** Skip `platform`'s test unless it is selected and its settings are all set. */ +function skipUnlessLive(platform: PlatformId) { + test.skip(!livePlatforms().includes(platform), `${platform} is not in LIBID_LIVE_PLATFORMS`) + test.skip(missing(platform).length > 0, `${missing(platform).join(', ')} not set`) +} test('a GitHub identity is proved in the browser', async ({ page, context }) => { - test.skip(!isLive('github'), 'GitHub is not in LIBID_LIVE_PLATFORMS') - const account = githubAccount() - test.skip(!account, lacking('github-account')) - await proveThroughTheUi(page, context, 'GitHub', account!.username, (popup) => - authorizeOnGitHub(popup, account!), + skipUnlessLive('github') + const account = { + username: env('GH_TEST_ALICE_USERNAME')!, + password: env('GH_TEST_ALICE_PASSWORD')!, + totpSecret: env('GH_TEST_ALICE_TOTP_SECRET')!, + } + await proveThroughTheUi(page, context, 'GitHub', account.username, (popup) => + authorizeOnGitHub(popup, account), ) }) test('an X identity is proved in the browser', async ({ page, context }) => { - test.skip(!isLive('x'), 'X is not in LIBID_LIVE_PLATFORMS') - const session = xSession() - const account = xAccount() - test.skip(!session || !account, lacking('x-account', 'x-session')) + skipUnlessLive('x') await presentAsPerson(context) - await restoreXSession(context, session!) - await proveThroughTheUi(page, context, 'X', account!.username, (popup) => authorizeOnX(popup)) + await restoreXSession(context, xSession()!) + await proveThroughTheUi(page, context, 'X', env('X_TEST_ALICE_USERNAME')!, authorizeOnX) }) test('a Google identity is proved in the browser', async ({ page, context }) => { - test.skip(!isLive('google'), 'Google is not in LIBID_LIVE_PLATFORMS') - const session = googleSession() - const account = googleAccount() - test.skip(!session || !account, lacking('google-account', 'google-session')) + skipUnlessLive('google') + const email = env('GOOGLE_TEST_ALICE_EMAIL')! await presentAsPerson(context) - await restoreSession(context, session!) - await proveThroughTheUi(page, context, 'Google', account!.email, (popup) => - authorizeOnGoogle(popup, account!.email), + await restoreSession(context, googleSession()!) + await proveThroughTheUi(page, context, 'Google', email, (popup) => + authorizeOnGoogle(popup, email), ) }) diff --git a/ts/apps/dev/e2e/github.test.ts b/ts/apps/dev/e2e/github.test.ts index e6872860..07a1c978 100644 --- a/ts/apps/dev/e2e/github.test.ts +++ b/ts/apps/dev/e2e/github.test.ts @@ -1,4 +1,4 @@ -// The TOTP code against the reference vector the Rust suite checks (github.rs). +// The TOTP code against the reference vector libid-server-rs' github.rs checks. import assert from 'node:assert/strict' import { test } from 'node:test' import { totp } from './github.ts' @@ -7,13 +7,3 @@ test('the TOTP code matches the reference vector, spaces and case aside', () => assert.equal(totp('JBSWY3DPEHPK3PXP', 1_700_000_000_000), '324550') assert.equal(totp('jbsw y3dp ehpk 3pxp', 1_700_000_000_000), '324550') }) - -test('a key that is not base32 names its setting, not its value', () => { - assert.throws( - () => totp('not-base32!'), - (error: Error) => { - assert.equal(error.message, 'GH_TEST_ALICE_TOTP_SECRET is not a base32 key') - return true - }, - ) -}) diff --git a/ts/apps/dev/e2e/github.ts b/ts/apps/dev/e2e/github.ts index 11720ef9..46dfdefb 100644 --- a/ts/apps/dev/e2e/github.ts +++ b/ts/apps/dev/e2e/github.ts @@ -1,57 +1,31 @@ import type { Page } from '@playwright/test' import * as OTPAuth from 'otpauth' import { drivePopup, type Pause } from './popup.ts' -import type { GitHubAccount } from './settings.ts' -/** The longest a fill or click may take; a navigation gets twice that. */ -const STEP_TIMEOUT = 5_000 -/** How long a submitted login form may still show before it counts as refused. */ -const LOGIN_SETTLE = 15_000 - -/** Codes the TOTP step submits before a refusal fails the run. */ -const TOTP_ATTEMPTS = 2 -/** How long a submitted code's form may still show before it counts as refused. */ -const TOTP_SETTLE = 5_000 +export interface GitHubAccount { + username: string + password: string + /** The base32 key of the account's authenticator app. */ + totpSecret: string +} /** The TOTP code for the base32 `secret`, spaces and case aside, at `time` (ms). */ export function totp(secret: string, time = Date.now()): string { - let key: OTPAuth.Secret - try { - key = OTPAuth.Secret.fromBase32(secret.replace(/\s/g, '').toUpperCase()) - } catch { - throw new Error('GH_TEST_ALICE_TOTP_SECRET is not a base32 key') - } - return new OTPAuth.TOTP({ secret: key, algorithm: 'SHA1', digits: 6, period: 30 }).generate({ - timestamp: time, - }) + return new OTPAuth.TOTP({ + secret: OTPAuth.Secret.fromBase32(secret.replace(/\s/g, '').toUpperCase()), + algorithm: 'SHA1', + digits: 6, + period: 30, + }).generate({ timestamp: time }) } -/** GitHub's flash message on the page, whitespace collapsed, up to 200 characters. */ -async function flashText(popup: Page): Promise { - const flash = await popup - .locator('.flash-error, .flash-warn, #js-flash-container') - .first() - .innerText({ timeout: 1_000 }) - .catch(() => '') - return flash.split(/\s+/).join(' ').trim().slice(0, 200) -} - -/** What the login has done so far; each handler reads and updates it. */ +/** What the login has done so far: the request a restart reopens, the login filled, the + * step whose code went, the last click (none again within 5 s), and back-off counts. */ interface Progress { - /** The first GitHub page of the run: the authorization request, which a restart reopens. */ start?: string - /** When the login form was last submitted. */ - filledAt?: number - /** The 30-second step whose code was last submitted, and how many were. */ + filled: boolean totpStep?: number - totpAttempts: number - totpSubmittedAt?: number - /** GitHub's message for the last refused code. */ - totpRefusal?: string - /** When the authorize button was last clicked; it is not clicked again within 5 s. */ - authorizedAt: number - /** When the 2FA checkup was last skipped; it is not skipped again within 5 s. */ - skippedCheckupAt: number + clickedAt: number rateLimited: number errored: number } @@ -69,102 +43,37 @@ async function backOff( await pause(step * count, `${what} ${count} times`) } -async function onErrorPage(popup: Page, progress: Progress, pause: Pause) { - 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, { timeout: STEP_TIMEOUT * 2 }).catch(() => {}) - progress.filledAt = undefined - progress.totpStep = undefined +/** Click `target` unless a button was clicked in the last 5 s; the page may be leaving. */ +async function clickOnce(progress: Progress, target: ReturnType) { + if (Date.now() - progress.clickedAt < 5_000) return + progress.clickedAt = Date.now() + await target.click({ timeout: 5_000 }).catch(() => {}) } -async function onLogin( - popup: Page, - account: GitHubAccount, - progress: Progress, - text: string, - pause: Pause, -) { - if (text.includes('too many requests') || text.includes('rate limit')) { - await backOff(progress, 'rateLimited', 30_000, 'GitHub rate-limited the login', pause) - await popup.reload({ timeout: STEP_TIMEOUT * 2 }).catch(() => {}) - progress.filledAt = undefined - return - } - if (text.includes('incorrect username or password')) - throw new Error('GitHub refused the username or password: check GH_TEST_ALICE_*') - if (!(await popup.locator('#login_field').count())) return - // The form still showing well after a submit is GitHub refusing the sign-in. - if (progress.filledAt !== undefined) { - if (Date.now() - progress.filledAt < LOGIN_SETTLE) return - const flash = await flashText(popup) - throw new Error(`GitHub showed the login form again after sign-in: ${flash || 'no message'}`) - } - // A step that fails is tried again on the next poll, inside drivePopup's budget. - try { - await popup.fill('#login_field', account.username, { timeout: STEP_TIMEOUT }) - await popup.fill('#password', account.password, { timeout: STEP_TIMEOUT }) - await popup.click('input[type=submit][name=commit]', { timeout: STEP_TIMEOUT }) - } catch { - return - } - progress.filledAt = Date.now() +async function onLogin(popup: Page, account: GitHubAccount, progress: Progress) { + if (progress.filled || !(await popup.locator('#login_field').count())) return + progress.filled = true + await popup.fill('#login_field', account.username) + await popup.fill('#password', account.password) + await popup.click('input[type=submit][name=commit]') } +/** One code per 30-second step, submitted once: GitHub submits a full code itself. */ async function onTotp(popup: Page, account: GitHubAccount, progress: Progress) { - // One clock read for the step and its code. const now = Date.now() const step = Math.floor(now / 30_000) - if (!(await popup.locator('#app_totp').count())) return - // A submitted code gets time to land before its form showing counts as a refusal. - if (now - (progress.totpSubmittedAt ?? 0) < TOTP_SETTLE || progress.totpStep === step) return - // The code form still showing, settled and in a later step: GitHub refused the code. - if (progress.totpAttempts >= TOTP_ATTEMPTS) - throw new Error( - `GitHub refused ${TOTP_ATTEMPTS} TOTP codes${progress.totpRefusal ? ` (${progress.totpRefusal})` : ''}: check GH_TEST_ALICE_TOTP_SECRET and this host's clock`, - ) - const code = totp(account.totpSecret, now) - try { - await popup.fill('#app_totp', code, { timeout: STEP_TIMEOUT }) - } catch { - return - } - // GitHub submits the code on its own once six digits are in; the button is - // pressed only for a form still there after a moment, so a code goes once. - const submitted = await popup + if (progress.totpStep === step || !(await popup.locator('#app_totp').count())) return + progress.totpStep = step + await popup.fill('#app_totp', totp(account.totpSecret, now)) + const left = await popup .waitForURL((url) => url.pathname !== '/sessions/two-factor/app', { timeout: 3_000 }) .then(() => true) .catch(() => false) - if (!submitted) + if (!left) await popup .locator('button[type=submit]') .click({ timeout: 2_000 }) .catch(() => {}) - progress.totpStep = step - progress.totpSubmittedAt = Date.now() - progress.totpAttempts++ -} - -async function onAuthorize(popup: Page, progress: Progress, text: string) { - const authorize = popup.locator("button[name='authorize'][value='1']") - 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", - ) -} - -async function onTwoFactorCheckup(popup: Page, progress: Progress) { - if (Date.now() - progress.skippedCheckupAt < 5_000) return - progress.skippedCheckupAt = Date.now() - await popup - .getByText('skip 2FA verification') - .click({ timeout: 5_000 }) - .catch(() => {}) } /** Act on one GitHub page. */ @@ -181,43 +90,42 @@ async function onGitHubPage( if ( text.includes("couldn't respond to your request in time") || text.includes('something went wrong') - ) - 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')) { - // The code's POST target answering a refusal: keep GitHub's reason first. - if (text.includes('failed')) - progress.totpRefusal = (await flashText(popup)) || 'two-factor authentication failed' - await popup - .goto('https://github.com/sessions/two-factor/app', { timeout: STEP_TIMEOUT * 2 }) - .catch(() => {}) - return - } - // GitHub's periodic 2FA checkup, served in place of the page asked for. - if (text.includes('verify your two-factor authentication')) - return onTwoFactorCheckup(popup, progress) - if (path === '/login/oauth/authorize') return onAuthorize(popup, progress, text) - if (path.startsWith('/sessions/verified-device')) + ) { + 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 + progress.totpStep = undefined + } else if (path === '/login' || path === '/session') { + if (text.includes('too many requests') || text.includes('rate limit')) { + await backOff(progress, 'rateLimited', 30_000, 'GitHub rate-limited the login', pause) + await popup.reload().catch(() => {}) + progress.filled = false + } else await onLogin(popup, account, progress) + } else if (path === '/sessions/two-factor/app') await onTotp(popup, account, progress) + else if (path.startsWith('/sessions/two-factor')) + await popup.goto('https://github.com/sessions/two-factor/app').catch(() => {}) + else if (path.startsWith('/sessions/verified-device')) throw new Error('GitHub asked for device verification: the account has no TOTP method') + // GitHub's periodic 2FA checkup, served in place of the page asked for. + else if (text.includes('verify your two-factor authentication')) + await clickOnce(progress, popup.getByText('skip 2FA verification')) + else if (path === '/login/oauth/authorize') { + const authorize = popup.locator("button[name='authorize'][value='1']") + if (await authorize.count()) await clickOnce(progress, authorize) + 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", + ) + } } /** * 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, skip the 2FA checkup, approve the OAuth app, back off on rate-limit pages, and start the - * authorization over on GitHub's error page. + * Bridge's callback. A port of libid-server-rs' ceremony-tests/src/browser/github.rs. */ export async function authorizeOnGitHub(popup: Page, account: GitHubAccount) { - const progress: Progress = { - authorizedAt: 0, - skippedCheckupAt: 0, - totpAttempts: 0, - rateLimited: 0, - errored: 0, - } + const progress: Progress = { filled: false, clickedAt: 0, rateLimited: 0, errored: 0 } await drivePopup(popup, { name: 'GitHub', onHost: (url) => url.hostname === 'github.com', diff --git a/ts/apps/dev/e2e/google.ts b/ts/apps/dev/e2e/google.ts index cb37a753..ebf8b285 100644 --- a/ts/apps/dev/e2e/google.ts +++ b/ts/apps/dev/e2e/google.ts @@ -1,9 +1,5 @@ import type { Page } from '@playwright/test' -import { clickFound, drivePopup, evaluateWithin } from './popup.ts' -import { onGoogle } from './session.ts' - -/** How long the popup may stay on a Google host other than accounts.google.com. */ -const ELSEWHERE_GRACE = 5_000 +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][] = [ @@ -37,26 +33,15 @@ const findNext = (email: string) => `(() => { */ export async function authorizeOnGoogle(popup: Page, email: string) { let clickedAt = 0 - let elsewhereSince: number | undefined await drivePopup(popup, { name: 'Google', - onHost: (url) => onGoogle(url.hostname), + onHost: (url) => url.hostname === 'accounts.google.com', step: async (url, text) => { - // Another Google host has no handler: fail on one that stays, naming it. - if (url.hostname !== 'accounts.google.com') { - elsewhereSince ??= Date.now() - if (Date.now() - elsewhereSince > ELSEWHERE_GRACE) - throw new Error( - `Google left the popup on ${url.hostname}${url.pathname}, which no step drives`, - ) - return - } - elsewhereSince = undefined 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 evaluateWithin(popup, LOGIN_VISIBLE, false)) + 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` (libid-server-rs, branch feat/live-ceremony-tests).', ) diff --git a/ts/apps/dev/e2e/popup.ts b/ts/apps/dev/e2e/popup.ts index 8e58283f..77c9ee6c 100644 --- a/ts/apps/dev/e2e/popup.ts +++ b/ts/apps/dev/e2e/popup.ts @@ -1,9 +1,8 @@ import { setTimeout as sleep } from 'node:timers/promises' -import type { Page, Request } from '@playwright/test' +import type { Page } from '@playwright/test' import { BRIDGE } from '../local.ts' -import { DRIVER } from './budgets.ts' -const BUDGET_MS = DRIVER +const BUDGET_MS = 180_000 /** Where every platform returns the popup: the local Bridge's OAuth callback. */ const CALLBACK = new URL('/auth/callback', BRIDGE) @@ -28,49 +27,27 @@ const isCallback = (url: string) => { return parsed?.origin === CALLBACK.origin && parsed.pathname === CALLBACK.pathname } -const returns = new WeakMap() +const returned = new WeakSet() -/** - * Watch `popup` for its return to Bridge's callback, from now on. Call it as - * the popup appears (in the `page` event's predicate): a platform that needs - * no consent redirects at once. A request, not a committed page, counts: the - * callback may redirect on at once. - */ -export function watchReturn(popup: Page): { returned: boolean } { - let watch = returns.get(popup) - if (!watch) { - const state = { returned: isCallback(popup.url()) } - popup.on('request', (request: Request) => { - if (request.isNavigationRequest() && isCallback(request.url())) state.returned = true - }) - returns.set(popup, state) - watch = state - } - return watch -} - -/** What a step that outlasts the driver's budget resolves to. */ -const LATE: unique symbol = Symbol('late') +/** Note `popup`'s navigation to Bridge's callback from now on; call it as the popup appears. */ +export const watchReturn = (popup: Page) => + popup.on('request', (request) => { + if (request.isNavigationRequest() && isCallback(request.url())) returned.add(popup) + }) const failures = new WeakMap Promise>() -/** - * Let `drivePopup` stop early when the app's run for `popup` failed: `check` - * resolves to the app's reason, or undefined while the run goes on. - */ +/** Let `drivePopup` stop with the app's reason once `check` reports its run failed. */ export const watchAppFailure = (popup: Page, check: () => Promise) => failures.set(popup, check) /** * 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. + * Bridge's callback; fail on the budget, naming the page it stopped on. */ export async function drivePopup(popup: Page, driver: Driver) { const deadline = Date.now() + BUDGET_MS - const watch = watchReturn(popup) - const returned = () => watch.returned || (!popup.isClosed() && isCallback(popup.url())) + const hasReturned = () => returned.has(popup) || (!popup.isClosed() && isCallback(popup.url())) const pause: Pause = async (ms, reason) => { if (Date.now() + ms > deadline) throw new Error( @@ -78,24 +55,9 @@ export async function drivePopup(popup: Page, driver: Driver) { ) await sleep(ms) } - /** - * `work`'s value, or LATE once the budget runs out first: late work is left - * behind, its rejection handled. - */ - const withinBudget = async (work: Promise): Promise => { - work.catch(() => {}) - const budget = new AbortController() - return Promise.race([ - work, - sleep(Math.max(0, deadline - Date.now()), LATE, { signal: budget.signal }).catch( - (): typeof LATE => LATE, - ), - ]).finally(() => budget.abort()) - } while (Date.now() < deadline) { - if (returned()) return - const failed = await withinBudget(failures.get(popup)?.() ?? Promise.resolve(undefined)) - if (failed === LATE) break + if (hasReturned()) return + const failed = await failures.get(popup)?.() if (failed) throw new Error(`The app's run failed during the ${driver.name} authorization: ${failed}`) if (popup.isClosed()) @@ -109,26 +71,16 @@ export async function drivePopup(popup: Page, driver: Driver) { .innerText({ timeout: 2_000 }) .catch(() => '') const text = visible.split(/\s+/).join(' ').trim().toLowerCase().replaceAll('’', "'") - // A step that outlasts the budget is left behind: the error names the page. - if ((await withinBudget(driver.step(url, text, pause))) === LATE) break + await driver.step(url, text, pause) } await sleep(250) } - if (returned()) return + if (hasReturned()) return throw new Error( `No ${driver.name} authorization in ${BUDGET_MS / 1000} s; stopped on ${popup.url()}`, ) } -/** Evaluate `expression` in `popup`, or `fallback` when it fails or takes over 5 s. */ -export function evaluateWithin(popup: Page, expression: string, fallback: T): Promise { - const timer = new AbortController() - return Promise.race([ - popup.evaluate(expression).catch(() => fallback), - sleep(5_000, fallback, { signal: timer.signal }).catch(() => fallback), - ]).finally(() => timer.abort()) -} - /** * Evaluate `find`, a page expression for the control to press or nothing, * then click that control for real. Returns whether there was one. @@ -141,7 +93,7 @@ export async function clickFound(popup: Page, find: string): Promise { target.setAttribute('data-libid-click', '1'); return true; })()` - if (!(await evaluateWithin(popup, mark, false))) return false + if (!(await popup.evaluate(mark).catch(() => false))) return false await popup .locator('[data-libid-click="1"]') .click({ timeout: 5_000 }) diff --git a/ts/apps/dev/e2e/preflight.ts b/ts/apps/dev/e2e/preflight.ts deleted file mode 100644 index 101498db..00000000 --- a/ts/apps/dev/e2e/preflight.ts +++ /dev/null @@ -1,17 +0,0 @@ -// What a live run checks before its stack starts, in the runner process. -import { totp } from './github.ts' -import { googleSession, xSession } from './session.ts' -import { checkSettings, githubAccount, tooling } from './settings.ts' - -/** - * The settings, then under LIBID_REQUIRE_LIVE each selected platform's saved - * session and TOTP key: a bad one fails in seconds, not after the stack. - */ -export function preflight() { - checkSettings() - const { requireLive, platforms } = tooling() - if (!requireLive) return - if (platforms.includes('github')) totp(githubAccount()!.totpSecret) - if (platforms.includes('x')) xSession() - if (platforms.includes('google')) googleSession() -} diff --git a/ts/apps/dev/e2e/scan-secrets.test.ts b/ts/apps/dev/e2e/scan-secrets.test.ts deleted file mode 100644 index 51bc0919..00000000 --- a/ts/apps/dev/e2e/scan-secrets.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -// scan-secrets.ts run as CI runs it, against logs holding fake markers. -import assert from 'node:assert/strict' -import { spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { after, test } from 'node:test' -import { fileURLToPath } from 'node:url' -import { SCANNED } from './settings.ts' - -const SCRIPT = fileURLToPath(new URL('./scan-secrets.ts', import.meta.url)) -const FOUND = 2 -const NOTHING_TO_LOOK_FOR = 3 -const UNREADABLE = 4 -const directory = mkdtempSync(join(tmpdir(), 'scan-secrets-')) -after(() => rmSync(directory, { recursive: true, force: true })) -const base64 = (text: string) => Buffer.from(text).toString('base64') - -/** A file in the test's directory holding `text`. */ -function file(text: string): string { - const path = join(directory, Math.random().toString(36).slice(2)) - writeFileSync(path, text) - return path -} - -/** The scan's exit code for a log holding `log`, with `env` as the step's settings. */ -function scan(log: string, env: Record): number | null { - const clean = Object.fromEntries( - Object.entries(process.env).filter(([name]) => !/_TEST_ALICE_|^LIBID_|^CI$/.test(name)), - ) - const run = spawnSync(process.execPath, [SCRIPT, file(log)], { - env: { ...clean, ...env }, - encoding: 'utf8', - }) - if (run.status === FOUND) assert.match(run.stderr, /^Secrets in: /) - return run.status -} - -const cookies = (value: string) => JSON.stringify([{ name: 'SID', value, domain: '.google.com' }]) -const storageState = base64( - JSON.stringify({ - cookies: [{ name: 'SID', value: 'FAKE/MARKER=123', domain: '.google.com' }], - origins: [ - { - origin: 'https://accounts.google.com', - localStorage: [{ name: 'k', value: 'LSVALUE-98765' }], - }, - ], - }), -) - -test('the scan looks for exactly the SCANNED variables', () => { - for (const name of SCANNED) { - const env = name.endsWith('_COOKIES_FILE') - ? { [name]: file(cookies(`MARKER-${name}`)) } - : name.endsWith('_COOKIES') - ? { [name]: base64(cookies(`MARKER-${name}`)) } - : { [name]: `MARKER-${name}` } - assert.equal(scan(`x MARKER-${name} y`, env), FOUND, name) - } - assert.equal(scan('x MARKER-USERNAME y', { GH_TEST_ALICE_USERNAME: 'MARKER-USERNAME' }), 0) -}) - -test('a secret in the log fails the scan, in each form it is looked for', () => { - const password = { GH_TEST_ALICE_PASSWORD: 'FAKE"QUOTE-12345' } - assert.equal(scan('x FAKE"QUOTE-12345 y', password), FOUND) - assert.equal(scan('x FAKE\\"QUOTE-12345 y', password), FOUND) - assert.equal(scan('x FAKE%22QUOTE-12345 y', password), FOUND) - const google = { GOOGLE_TEST_ALICE_COOKIES: storageState } - assert.equal(scan('x FAKE%2FMARKER%3D123 y', google), FOUND) - assert.equal(scan('x LSVALUE-98765 y', google), FOUND) - assert.equal(scan(`x ${storageState} y`, google), FOUND) - const capitalised = base64('[{"Name":"SID","Value":"CAPITAL-VALUE-1","Domain":".google.com"}]') - assert.equal(scan('x CAPITAL-VALUE-1 y', { GOOGLE_TEST_ALICE_COOKIES: capitalised }), FOUND) -}) - -test('an undecodable session is still looked for by its value strings', () => { - const truncated = base64('[{"Name":"auth_token","Value":"FAKEMARKER-123456"') - assert.equal(scan('x FAKEMARKER-123456 y', { X_TEST_ALICE_COOKIES: truncated }), FOUND) - const badEntry = base64('[{"name":"a","value":"BADENTRY-123456","domain":1}]') - assert.equal(scan('x BADENTRY-123456 y', { X_TEST_ALICE_COOKIES: badEntry }), FOUND) - assert.equal(scan('clean', { X_TEST_ALICE_COOKIES: '!!!not base64' }), 0) -}) - -test('values under 8 characters are not looked for; a clean log passes', () => { - assert.equal(scan('x SHORT-7 y', { GH_TEST_ALICE_PASSWORD: 'SHORT-7' }), 0) - assert.equal(scan('clean', { GH_TEST_ALICE_PASSWORD: 'FAKE-PASSWORD-1' }), 0) -}) - -test('under CI, a scan with no secret to look for fails', () => { - assert.equal(scan('clean', { CI: 'true' }), NOTHING_TO_LOOK_FOR) - assert.equal(scan('clean', {}), 0) -}) - -test('a session file the scan cannot read fails it, naming the variable only', () => { - const missingFile = join(directory, 'no-such-session.json') - const run = spawnSync(process.execPath, [SCRIPT, file('clean')], { - env: { PATH: process.env.PATH, X_TEST_ALICE_COOKIES_FILE: missingFile }, - encoding: 'utf8', - }) - assert.equal(run.status, UNREADABLE) - assert.match(run.stderr, /X_TEST_ALICE_COOKIES_FILE/) - assert.doesNotMatch(run.stderr, /no-such-session/) -}) - -test('settings in the env file are scanned too, its relative paths beside it', () => { - const sessionFile = file(cookies('ENVFILE-MARKER-1')) - const envFile = file(`X_TEST_ALICE_COOKIES_FILE=${sessionFile.split('/').pop()}\n`) - assert.equal(scan('x ENVFILE-MARKER-1 y', { LIBID_TEST_ENV_FILE: envFile }), FOUND) -}) diff --git a/ts/apps/dev/e2e/scan-secrets.ts b/ts/apps/dev/e2e/scan-secrets.ts deleted file mode 100644 index 2091568e..00000000 --- a/ts/apps/dev/e2e/scan-secrets.ts +++ /dev/null @@ -1,97 +0,0 @@ -// Refuse to publish a failed run's stack log when it holds a test secret: the -// value of each variable settings.ts' SCANNED lists, and for a saved session -// every value it holds (its cookies' and, for a storage state, its origins' -// localStorage values), each also URL-encoded and JSON-escaped. A session -// that does not decode is still looked for as set. Paths are the arguments; a -// missing file is skipped. Settings come from the environment and the file -// LIBID_TEST_ENV_FILE names, as settings.ts reads them. Exit 2: a secret was -// found; 3: under CI, there was none to look for; 4: a *_COOKIES_FILE could -// not be read. Nothing secret is printed. -import { existsSync, readFileSync } from 'node:fs' -import { settingPath } from '../paths.ts' -import { exportCookies } from './session.ts' -import { envFile, loadEnvFile, SCANNED, tooling } from './settings.ts' - -const settingsFile = envFile() -if (settingsFile) loadEnvFile(settingsFile) - -const environment = (name: string) => process.env[name] - -/** The `value` strings of `items`, where it is a list. */ -const values = (items: unknown) => - Array.isArray(items) ? items.map((item) => (item as { value?: unknown } | null)?.value) : [] - -/** Every `"value": "…"` string `text` holds, in any spelling of the key. */ -const valueStrings = (text: string) => - [...text.matchAll(/"value"\s*:\s*"([^"\\]*)"/gi)].map((match) => match[1]) - -/** - * The values a session export holds: its cookies' and its origins' - * localStorage; where it is not JSON or no cookie list, its value strings. - */ -function heldValues(text: string): unknown[] { - let parsed: unknown - try { - parsed = JSON.parse(text) - } catch { - return valueStrings(text) - } - const cookies = exportCookies(parsed) - const origins = (parsed as { origins?: unknown } | null)?.origins - return [ - ...(cookies ? cookies.map((cookie) => cookie.value) : valueStrings(text)), - ...(Array.isArray(origins) ? origins : []).flatMap((origin) => - values((origin as { localStorage?: unknown } | null)?.localStorage), - ), - ] -} - -/** What to look for of SCANNED variable `name`: its value, and a session's held values. */ -function secretsOf(name: string): unknown[] { - const value = environment(name) - if (!value) return [] - if (name.endsWith('_COOKIES_FILE')) { - // A session file the scan cannot read could hold anything: fail closed. - try { - return heldValues(readFileSync(settingPath(environment, value)!, 'utf8')) - } catch { - console.error(`Cannot read the file ${name} names, so cannot scan for its values`) - process.exit(4) - } - } - if (!name.endsWith('_COOKIES')) return [value] - return [value.trim(), ...heldValues(Buffer.from(value.trim(), 'base64').toString('utf8'))] -} - -/** A secret as written, URL-encoded and JSON-escaped. */ -const forms = (secret: string) => [ - secret, - encodeURIComponent(secret), - JSON.stringify(secret).slice(1, -1), -] - -const secrets = [ - ...new Set( - SCANNED.flatMap(secretsOf) - .filter((secret): secret is string => typeof secret === 'string' && secret.length >= 8) - .flatMap(forms), - ), -] - -// Under CI a scan with nothing to look for would pass anything: the step lacks its secrets. -if (tooling().ci && !secrets.length) { - console.error("No test secret to look for: give this step the job's secrets (see live-e2e.yml)") - process.exit(3) -} - -const leaking = process.argv - .slice(2) - .filter(existsSync) - .filter((file) => { - const bytes = readFileSync(file) - return secrets.some((secret) => bytes.includes(secret)) - }) -if (leaking.length) { - console.error(`Secrets in: ${leaking.join(', ')}`) - process.exit(2) -} diff --git a/ts/apps/dev/e2e/session.test.ts b/ts/apps/dev/e2e/session.test.ts index b7283826..89ee324c 100644 --- a/ts/apps/dev/e2e/session.test.ts +++ b/ts/apps/dev/e2e/session.test.ts @@ -1,160 +1,35 @@ -// A synthetic `ceremony export` through the mapping restoreSession applies. +// The mapping restoreSession applies, on a synthetic export. import assert from 'node:assert/strict' import { test } from 'node:test' -import { cookieParams, onGoogle, onX, parseExport, type Stored } from './session.ts' - -const NOW = 1_800_000_000 - -const exported: Stored[] = [ - // CDP's camelCase, a domain cookie, with a partition as CDP reports it. - { - name: 'auth_token', - value: 'v1', - domain: '.x.com', - path: '/', - secure: true, - httpOnly: true, - sameSite: 'no_restriction', - expires: NOW + 60, - partitionKey: { topLevelSite: 'https://x.com', hasCrossSiteAncestor: false }, - }, - // snake_case, host-only, a path, a session cookie, partitioned by site alone. - { - name: 'ct0', - value: 'v2', - domain: 'x.com', - path: '/i', - http_only: false, - same_site: 'Lax', - expires: 0, - partition_key: 'https://x.com', - }, - // A __Host- cookie, unpartitioned, with `expirationDate`. - { name: '__Host-GAPS', value: 'v3', domain: 'accounts.google.com', expirationDate: NOW + 60 }, - // Expired: left out. - { name: 'old', value: 'v4', domain: '.x.com', expires: NOW - 1 }, -] - -test('a domain cookie keeps its domain and path; of its partition, the site', () => { - const [domain] = cookieParams(exported) - assert.deepEqual(domain, { - name: 'auth_token', - value: 'v1', - domain: '.x.com', - path: '/', - secure: true, - httpOnly: true, - sameSite: 'None', - expires: NOW + 60, - // Playwright takes the top-level site alone: hasCrossSiteAncestor (false - // in the export) is not expressible, and Playwright sends true. - partitionKey: 'https://x.com', - }) - assert.equal(domain && 'hasCrossSiteAncestor' in domain, false) -}) - -test('a host-only cookie goes on its host, without a dot, with its exact path', () => { - const [, hostOnly] = cookieParams(exported) - assert.deepEqual(hostOnly, { - name: 'ct0', - value: 'v2', - domain: 'x.com', - path: '/i', - secure: true, - httpOnly: false, - sameSite: 'Lax', - expires: -1, - partitionKey: 'https://x.com', - }) -}) - -test('a __Host- cookie is placed by url alone', () => { - const [, , host] = cookieParams(exported) - assert.equal(host?.url, 'https://accounts.google.com/') - assert.equal(host && ('domain' in host || 'path' in host), false) - assert.equal(host?.expires, NOW + 60) - assert.equal(host && 'partitionKey' in host, false) -}) - -test('each cookie goes on every domain given', () => { - const params = cookieParams(exported.slice(0, 3), (domain) => [ - domain, - domain.replace('x.com', 'twitter.com'), - ]) - assert.deepEqual( - params.map((param) => param.domain ?? param.url), - [ - '.x.com', - '.twitter.com', - 'x.com', - 'twitter.com', - 'https://accounts.google.com/', - 'https://accounts.google.com/', - ], - ) -}) - -test("an export keeps the platform's unexpired cookies, as a list or under `cookies`", () => { - const list = JSON.stringify(exported) - assert.deepEqual( - parseExport(list, onX, NOW)?.map((cookie) => cookie.name), - ['auth_token', 'ct0'], +import { cookieParams } from './session.ts' + +test('cookies map to addCookies as the export spells them', () => { + const exported = [ + { name: 'a', value: '1', domain: '.x.com', sameSite: 'no_restriction', expires: 9e9 }, + { + name: 'b', + value: '2', + domain: 'x.com', + path: '/i', + same_site: '', + partitionKey: 'https://x.com', + }, + ] + const [domain, copy, hostOnly] = cookieParams(exported, (d) => + d[0] === '.' ? [d, '.twitter.com'] : [d], ) assert.deepEqual( - parseExport(JSON.stringify({ cookies: exported, origins: [] }), onGoogle, NOW)?.map( - (cookie) => cookie.name, - ), - ['__Host-GAPS'], + [domain?.domain, domain?.path, domain?.sameSite, domain?.expires, copy?.domain], + ['.x.com', '/', 'None', 9e9, '.twitter.com'], ) - const lookalike = JSON.stringify([ - { name: 'SID', value: 'fake', domain: 'evilgoogle.com' }, - { name: 'SID', value: 'fake', domain: '.google.com' }, - ]) assert.deepEqual( - parseExport(lookalike, onGoogle, NOW)?.map((cookie) => cookie.domain), - ['.google.com'], - ) - assert.equal(parseExport('{"cookies": 1}', onX, NOW), undefined) - assert.equal(parseExport('not json', onX, NOW), undefined) -}) - -test('an export entry is a cookie only with string name, value and domain', () => { - const capitalised = JSON.stringify([ - { Name: 'auth_token', Value: 'fake', Domain: '.x.com', Path: '/', Secure: true }, - ]) - const [cookie] = parseExport(capitalised, onX, NOW) ?? [] - assert.equal(cookie?.name, 'auth_token') - assert.equal(cookie?.domain, '.x.com') - assert.equal(cookie?.path, '/') - for (const bad of [ - '[null]', - '["auth_token"]', - '[{"name":"auth_token","value":"fake"}]', - '[{"name":"auth_token","value":1,"domain":".x.com"}]', - ]) - assert.equal(parseExport(bad, onX, NOW), undefined, bad) -}) - -test('an optional field of the wrong type makes the export unreadable', () => { - for (const field of [ - '"path":1', - '"secure":"yes"', - '"httpOnly":1', - '"sameSite":1', - '"expires":"soon"', - '"partitionKey":1', - ]) - assert.equal( - parseExport(`[{"name":"a","value":"b","domain":".x.com",${field}}]`, onX, NOW), - undefined, - field, - ) - assert.equal( - parseExport( - '[{"name":"a","value":"b","domain":".x.com","secure":null,"sameSite":null}]', - onX, - NOW, - )?.length, - 1, + [ + hostOnly?.domain, + hostOnly?.path, + 'sameSite' in hostOnly!, + hostOnly?.expires, + hostOnly?.partitionKey, + ], + ['x.com', '/i', false, -1, 'https://x.com'], ) }) diff --git a/ts/apps/dev/e2e/session.ts b/ts/apps/dev/e2e/session.ts index f3ef6817..68c4858d 100644 --- a/ts/apps/dev/e2e/session.ts +++ b/ts/apps/dev/e2e/session.ts @@ -1,17 +1,12 @@ +// Saved platform sessions: decoded, kept to the platform's hosts, restored. import { readFileSync } from 'node:fs' import type { BrowserContext } from '@playwright/test' -import type { SavedSession } from './settings.ts' -import * as settings from './settings.ts' +import { env, settingPath } from './settings.ts' -/** - * One cookie as libid-server-rs' `ceremony export` saves it (ceremony-tests - * src/browser/cookies.rs): snake_case or camelCase keys, `expirationDate` for - * `expires`, and a partition key as CDP's object or its top-level site alone. - */ +/** One cookie as libid-server-rs' `ceremony export` or Playwright saves it; both key casings occur. */ export interface Stored { name: string value: string - /** With a leading dot for a domain cookie; the host alone for a host-only one. */ domain: string path?: string secure?: boolean | null @@ -21,216 +16,92 @@ export interface Stored { sameSite?: string | null expires?: number expirationDate?: number - partition_key?: PartitionKey | string | null - partitionKey?: PartitionKey | string | null + partition_key?: unknown + partitionKey?: unknown } -interface PartitionKey { - topLevelSite?: string - top_level_site?: string - hasCrossSiteAncestor?: boolean -} - -/** The JSON text of a saved session setting. */ -const exportText = (saved: SavedSession) => - 'encoded' in saved - ? Buffer.from(saved.encoded.trim(), 'base64').toString('utf8') - : readFileSync(saved.path, 'utf8') - -/** A cookie's host, without the leading dot of a domain cookie. */ const hostOf = (cookie: Stored) => cookie.domain.replace(/^\./, '') - -/** A cookie's expiry in Unix seconds; 0 for a session cookie. */ -const expiresOf = (cookie: Stored) => cookie.expires ?? cookie.expirationDate ?? 0 - -/** Hosts a platform's session cookies live on, as the Rust suite's x_host and google_host admit. */ export const onX = (host: string) => host === 'x.com' || host.endsWith('.x.com') export const onGoogle = (host: string) => host === 'google.com' || host.endsWith('.google.com') -/** Capitalised spellings an export may use, as cookies.rs' serde aliases accept. */ -const CAPITALISED = { - Name: 'name', - Value: 'value', - Domain: 'domain', - Path: 'path', - Secure: 'secure', -} - -const isString = (value: unknown) => typeof value === 'string' -const isBoolean = (value: unknown) => typeof value === 'boolean' -const orNull = (check: (value: unknown) => boolean) => (value: unknown) => - value === null || check(value) - -/** The optional fields' types, as cookies.rs reads them; an absent field is fine. */ -const OPTIONAL: Record boolean> = { - path: isString, - secure: orNull(isBoolean), - http_only: isBoolean, - httpOnly: isBoolean, - same_site: orNull(isString), - sameSite: orNull(isString), - expires: (value) => typeof value === 'number', - expirationDate: (value) => typeof value === 'number', - partition_key: orNull((value) => isString(value) || typeof value === 'object'), - partitionKey: orNull((value) => isString(value) || typeof value === 'object'), -} - -/** - * One export entry as a cookie: an object with string name, value and domain, - * its optional fields of their types; else undefined. - */ -function storedOf(entry: unknown): Stored | undefined { - if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return undefined - const cookie: Record = { ...entry } - for (const [spelled, name] of Object.entries(CAPITALISED)) - if (spelled in cookie && !(name in cookie)) cookie[name] = cookie[spelled] - const { name, value, domain } = cookie - if (!isString(name) || !isString(value) || !isString(domain)) return undefined - for (const [field, valid] of Object.entries(OPTIONAL)) - if (cookie[field] !== undefined && !valid(cookie[field])) return undefined - return cookie as unknown as Stored -} - -/** - * Every cookie of a parsed export, as cookies.rs reads them: a list, or one - * under `cookies`; undefined when it is no such export or an entry is no - * cookie. - */ -export function exportCookies(parsed: unknown): Stored[] | undefined { - const cookies = Array.isArray(parsed) ? parsed : (parsed as { cookies?: unknown } | null)?.cookies - if (!Array.isArray(cookies)) return undefined - const read = cookies.map(storedOf) - return read.some((cookie) => cookie === undefined) ? undefined : (read as Stored[]) -} - /** - * The cookies of an export for the hosts `keep` admits, as the Rust suite's - * cookies::parse reads them (see exportCookies); an expired cookie is left - * out. Undefined when the text is no such export. + * The platform's cookies in `_COOKIES` (base64 JSON) or `_COOKIES_FILE`: + * a list, or one under `cookies`, one of them `signedIn` (the Rust suite's signed_in). */ -export function parseExport( - json: string, +function saved( + prefix: string, keep: (host: string) => boolean, - now = Date.now() / 1000, + signedIn: (cookie: Stored) => boolean, + needs: string, ): Stored[] | undefined { - let parsed: unknown + const encoded = env(`${prefix}_COOKIES`) + const file = env(`${prefix}_COOKIES_FILE`) + if (!encoded && !file) return undefined + let cookies: unknown try { - parsed = JSON.parse(json) + const parsed = JSON.parse( + encoded + ? Buffer.from(encoded.trim(), 'base64').toString('utf8') + : readFileSync(settingPath(file!), 'utf8'), + ) + cookies = Array.isArray(parsed) ? parsed : parsed?.cookies } catch { // The parser's message would quote the value. - return undefined } - return exportCookies(parsed)?.filter((cookie) => { - const expires = expiresOf(cookie) - return keep(hostOf(cookie)) && (expires <= 0 || expires >= now) - }) -} - -/** - * The platform's cookies of a saved session setting, or undefined when it is - * not set. `signedIn` says, as the Rust suite's signed_in does, whether the - * cookies hold a session; `needs` names what it looks for. - */ -function cookiesOf( - saved: SavedSession | undefined, - label: string, - keep: (host: string) => boolean, - signedIn: (cookie: Stored) => boolean, - needs: string, -): Stored[] | undefined { - if (!saved) return undefined - const cookies = parseExport(exportText(saved), keep) - if (!cookies) - throw new Error(`The saved ${label} session is not a JSON cookie list or { cookies }`) - if (!cookies.some(signedIn)) - throw new Error(`The saved ${label} session has no unexpired ${needs}`) - return cookies + if (!Array.isArray(cookies)) + throw new Error(`The saved ${prefix} session is not a JSON cookie list or { cookies }`) + const kept = (cookies as Stored[]).filter((cookie) => keep(hostOf(cookie))) + if (!kept.some(signedIn)) throw new Error(`The saved ${prefix} session has no ${needs}`) + return kept } -/** - * 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 = () => - cookiesOf( - settings.xSession(), - 'X_TEST_ALICE', - onX, - (cookie) => cookie.name === 'auth_token', - 'auth_token', - ) + saved('X_TEST_ALICE', onX, (cookie) => cookie.name === 'auth_token', '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 = () => - cookiesOf( - settings.googleSession(), + saved( 'GOOGLE_TEST_ALICE', onGoogle, - // A sign-in accounts.google.com honours (google.rs signed_in). (cookie) => cookie.domain.endsWith('accounts.google.com') && (cookie.name === 'LSID' || cookie.name === '__Host-1PLSID'), 'LSID or __Host-1PLSID on accounts.google.com', ) -const SAME_SITE = new Map([ - ['strict', 'Strict'], - ['lax', 'Lax'], - ['none', 'None'], - ['no_restriction', 'None'], -]) +const SAME_SITE: Record = { + strict: 'Strict', + lax: 'Lax', + none: 'None', + no_restriction: 'None', +} type CookieParam = Parameters[0][number] -/** - * The cookies to set for a saved session, as the Rust suite sets them: a - * domain cookie (leading dot) on its domain, a `__Host-` one by `url` alone, - * another host-only one on its host without a dot, which Chromium keeps - * host-only with its exact path. `domainsOf` - * gives the domains each cookie goes on. - */ +/** The cookies to set, on their own domain or each of `domainsOf`'s; a dotless one stays host-only. */ export function cookieParams( cookies: Stored[], domainsOf: (domain: string) => string[] = (domain) => [domain], ): CookieParam[] { return cookies.flatMap((cookie) => { - const expires = expiresOf(cookie) - const key = cookie.partition_key ?? cookie.partitionKey - // Playwright takes the partition's top-level site only: an exported - // hasCrossSiteAncestor is dropped, and Playwright sets it true. - const partitionKey = typeof key === 'string' ? key : (key?.topLevelSite ?? key?.top_level_site) - return domainsOf(cookie.domain).map((domain): CookieParam => { - const path = cookie.path || '/' - const placed = cookie.name.startsWith('__Host-') - ? { url: `https://${domain}/` } - : { domain, path } - return { - name: cookie.name, - value: cookie.value, - ...placed, - secure: cookie.secure ?? true, - httpOnly: cookie.http_only ?? cookie.httpOnly ?? false, - sameSite: SAME_SITE.get((cookie.same_site ?? cookie.sameSite ?? '').toLowerCase()), - expires: expires > 0 ? expires : -1, - ...(partitionKey && { partitionKey }), - } - }) + const sameSite = SAME_SITE[(cookie.same_site ?? cookie.sameSite ?? '').toLowerCase()] + const expires = cookie.expires ?? cookie.expirationDate ?? 0 + const partitionKey = cookie.partition_key ?? cookie.partitionKey + return 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, + expires: expires > 0 ? expires : -1, + ...(sameSite && { sameSite }), + ...(typeof partitionKey === 'string' && { partitionKey }), + })) }) } -/** Put saved cookies in the browser context, on their own domain or each of `domainsOf`'s. */ -export async function restoreSession( +export const restoreSession = ( context: BrowserContext, cookies: Stored[], domainsOf?: (domain: string) => string[], -) { - await context.addCookies(cookieParams(cookies, domainsOf)) -} +) => context.addCookies(cookieParams(cookies, domainsOf)) diff --git a/ts/apps/dev/e2e/settings.test.ts b/ts/apps/dev/e2e/settings.test.ts deleted file mode 100644 index 7e34f9df..00000000 --- a/ts/apps/dev/e2e/settings.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -// The settings table against the places that repeat it: the live workflow and -// the README. Read as text; no secrets, no network. -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { test } from 'node:test' -import type { PlatformId } from '@libid/ceremony' -import { STACK_START, STACK_STOP, TEST, TEST_STEPS } from './budgets.ts' -import { missing, type Need, PLATFORM_SECTIONS, SCANNED, VARIABLES } from './settings.ts' - -const read = (relative: string) => readFileSync(new URL(relative, import.meta.url), 'utf8') -const workflow = read('../../../../.github/workflows/live-e2e.yml') - -/** - * Minutes a job needs beside its test step: checkout, install, the browser - * install, any proxy, and the failure-output step. - */ -const JOB_ALLOWANCE = 10 - -/** The jobs that run live tests. */ -const JOBS = ['ceremony', 'google-ssm'] - -const isSetting = (name: string) => VARIABLES.some(([setting]) => setting === name) -/** A name of the kind the settings own. */ -const looksLikeSetting = (name: string) => /_TEST_ALICE_/.test(name) || name.startsWith('LIBID_') - -/** An `env:` entry's key and value, if `line` is one. */ -function envEntry(line: string): [string, string] | undefined { - const match = /^\s+([A-Z][A-Z0-9_]*):\s*(.*)$/.exec(line) - return match ? [match[1]!, match[2]!] : undefined -} - -/** Every name `text` reads as `secrets.NAME` or `vars.NAME`. */ -const referenced = (text: string) => - new Set([...text.matchAll(/\b(?:secrets|vars)\.([A-Za-z0-9_]+)/g)].map((match) => match[1]!)) - -/** The lines of job `name`. */ -function jobLines(name: string): string[] { - const lines = workflow.split('\njobs:\n')[1]!.split('\n') - const start = lines.indexOf(` ${name}:`) - assert.notEqual(start, -1, `the workflow has a job ${name}`) - const end = lines.findIndex((line, index) => index > start && /^ {2}[a-z][\w-]*:$/.test(line)) - return lines.slice(start, end === -1 ? undefined : end) -} - -/** The lines of job `name`'s step holding the line `marker`. */ -function step(name: string, marker: string): string[] { - const job = jobLines(name) - const at = job.findIndex((line) => line.trim() === marker) - assert.notEqual(at, -1, `job ${name} has a step with ${marker}`) - const isStep = (line: string) => line.startsWith(' - ') - let first = at - while (first > 0 && !isStep(job[first]!)) first-- - const next = job.findIndex((line, index) => index > at && isStep(line)) - return job.slice(first, next === -1 ? undefined : next) -} - -/** The lines of job `name`'s test step (`id: test`). */ -const testStep = (name: string) => step(name, 'id: test') - -test('every secret the workflow reads is a setting, passed under its own name', () => { - for (const name of [...workflow.matchAll(/\bsecrets\.([A-Za-z0-9_]+)/g)].map((m) => m[1]!)) - assert.ok(isSetting(name), `the workflow reads secret ${name}, which the settings do not list`) - for (const line of workflow.split('\n')) { - const entry = envEntry(line) - if (!entry || !looksLikeSetting(entry[0])) continue - assert.ok(isSetting(entry[0]), `the workflow sets ${entry[0]}, which the settings do not list`) - const read = referenced(entry[1]) - if (read.size) - assert.deepEqual(read, new Set([entry[0]]), `${entry[0]} carries its own setting`) - } -}) - -/** - * The platforms job `name`'s test step can prove: every one when the - * repository variable may choose, else the ones it lists. - */ -function selectable(entries: [string, string][], name: string): PlatformId[] { - const selection = entries.find(([key]) => key === 'LIBID_LIVE_PLATFORMS')?.[1] - assert.ok(selection, `job ${name}'s test step sets LIBID_LIVE_PLATFORMS`) - if (referenced(selection).size) return Object.keys(PLATFORM_SECTIONS) as PlatformId[] - const platforms = selection.split(',').map((platform) => platform.trim()) as PlatformId[] - for (const platform of platforms) - assert.ok(platform in PLATFORM_SECTIONS, `job ${name} selects unknown ${platform}`) - return platforms -} - -const entriesOf = (lines: string[]) => - lines.flatMap((line) => { - const entry = envEntry(line) - return entry ? [entry] : [] - }) - -test("each job's test step is given what every platform it can select requires", () => { - for (const name of JOBS) { - const entries = entriesOf(testStep(name)) - const given = new Set(entries.map(([key]) => key)) - const lookup = (variable: string) => (given.has(variable) ? 'set' : undefined) - const absent = selectable(entries, name).flatMap((platform) => - PLATFORM_SECTIONS[platform].flatMap((section) => missing(section, lookup)), - ) - assert.deepEqual(absent, [], `job ${name}'s test step is not given ${absent.join(', ')}`) - } -}) - -test("each job's failure-output step is given every scanned secret its test step is", () => { - for (const name of JOBS) { - const tested = new Set(entriesOf(testStep(name)).map(([key]) => key)) - const output = new Set( - entriesOf(step(name, 'uses: ./.github/actions/live-e2e-output')).map(([key]) => key), - ) - const absent = SCANNED.filter((secret) => tested.has(secret) && !output.has(secret)) - assert.deepEqual(absent, [], `job ${name}'s failure-output step lacks ${absent.join(', ')}`) - } -}) - -test('each test step outlasts its worst case, and each job its step and setup', () => { - const minutes = (ms: number) => ms / 60_000 - const sum = TEST_STEPS.reduce((total, step) => total + step, 0) - assert.ok( - TEST > sum, - `the test timeout ${minutes(TEST)} min is under its budgets' ${minutes(sum)}`, - ) - for (const name of JOBS) { - const step = testStep(name) - const timeout = Number(/^\s+timeout-minutes:\s*(\d+)/m.exec(step.join('\n'))?.[1]) - const worst = minutes( - STACK_START + selectable(entriesOf(step), name).length * TEST + STACK_STOP, - ) - assert.ok(timeout >= worst, `job ${name}'s test step allows ${timeout} min of ${worst}`) - const job = Number(/^ {4}timeout-minutes:\s*(\d+)/m.exec(jobLines(name).join('\n'))?.[1]) - assert.ok( - job >= timeout + JOB_ALLOWANCE, - `job ${name} allows ${job} min: its test step's ${timeout} and ${JOB_ALLOWANCE} for the rest`, - ) - } -}) - -test("the README's live tests table lists every setting as the table does", () => { - const rows = read('../README.md') - .split('\n') - .filter((line) => line.startsWith('| `')) - const needText = (need: Need) => - typeof need === 'string' ? need : `instead of \`${need.insteadOf}\`` - for (const [name, section, need] of VARIABLES) { - const row = rows.find((line) => line.startsWith(`| \`${name}\` |`)) - assert.ok(row, `the README lists ${name}`) - assert.ok( - row.startsWith(`| \`${name}\` | ${section} | ${needText(need)} |`), - `the README lists ${name} as in ${section}, ${needText(need)}: ${row}`, - ) - } - for (const row of rows) { - const name = row.slice(3).split('`')[0]! - assert.ok(isSetting(name), `the README lists ${name}, which the settings do not`) - } -}) diff --git a/ts/apps/dev/e2e/settings.ts b/ts/apps/dev/e2e/settings.ts index d93e2dc2..376c6c97 100644 --- a/ts/apps/dev/e2e/settings.ts +++ b/ts/apps/dev/e2e/settings.ts @@ -1,228 +1,72 @@ -// The settings the live tests read: every variable, its section and whether -// the section needs it, in VARIABLES; one typed reader per section. The names -// are those of libid-server-rs' ceremony-tests (src/settings.rs), which reads -// the same .env.test. An empty value is an absent one. +// The live tests' settings, under the names libid-server-rs' ceremony-tests +// reads from the same .env.test. An empty value counts as unset. import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' import { parseEnv } from 'node:util' import type { PlatformId } from '@libid/ceremony' -import { envFile as envFileOf, type Lookup, nonEmpty, settingPath } from '../paths.ts' -/** Whether a section needs a variable; `insteadOf` stands in for that required one. */ -export type Need = 'required' | 'optional' | { insteadOf: string } - -export type Section = - | 'github-account' - | 'x-account' - | 'x-session' - | 'google-account' - | 'google-session' - | 'tooling' - -/** Every variable the live tests read: its name, its section, and its need. */ -export const VARIABLES: readonly (readonly [name: string, section: Section, need: Need])[] = [ - ['GH_TEST_ALICE_USERNAME', 'github-account', 'required'], - ['GH_TEST_ALICE_PASSWORD', 'github-account', 'required'], - ['GH_TEST_ALICE_TOTP_SECRET', 'github-account', 'required'], - ['X_TEST_ALICE_USERNAME', 'x-account', 'required'], - ['X_TEST_ALICE_COOKIES', 'x-session', 'required'], - ['X_TEST_ALICE_COOKIES_FILE', 'x-session', { insteadOf: 'X_TEST_ALICE_COOKIES' }], - ['GOOGLE_TEST_ALICE_EMAIL', 'google-account', 'required'], - ['GOOGLE_TEST_ALICE_COOKIES', 'google-session', 'required'], - ['GOOGLE_TEST_ALICE_COOKIES_FILE', 'google-session', { insteadOf: 'GOOGLE_TEST_ALICE_COOKIES' }], - ['LIBID_TEST_ENV_FILE', 'tooling', 'optional'], - ['LIBID_LIVE_PLATFORMS', 'tooling', 'optional'], - ['LIBID_REQUIRE_LIVE', 'tooling', 'optional'], - ['LIBID_TEST_PROXY', 'tooling', 'optional'], - // Read by services.ts, in the stack's own process. - ['LIBID_STACK_LOGS', 'tooling', 'optional'], - ['HEADED', 'tooling', 'optional'], - // Set by CI services, such as GitHub Actions. - ['CI', 'tooling', 'optional'], -] - -/** The sections each platform's live test reads; the type keeps every platform listed. */ -export const PLATFORM_SECTIONS: Record = { - github: ['github-account'], - x: ['x-account', 'x-session'], - google: ['google-account', 'google-session'], +/** Each platform's variables; `A|B` is either. The type keeps every platform listed. */ +export const PLATFORM_VARIABLES: Record = { + github: ['GH_TEST_ALICE_USERNAME', 'GH_TEST_ALICE_PASSWORD', 'GH_TEST_ALICE_TOTP_SECRET'], + x: ['X_TEST_ALICE_USERNAME', 'X_TEST_ALICE_COOKIES|X_TEST_ALICE_COOKIES_FILE'], + google: ['GOOGLE_TEST_ALICE_EMAIL', 'GOOGLE_TEST_ALICE_COOKIES|GOOGLE_TEST_ALICE_COOKIES_FILE'], } +const PLATFORMS = Object.keys(PLATFORM_VARIABLES) as PlatformId[] -const PLATFORMS = Object.keys(PLATFORM_SECTIONS) as PlatformId[] - -/** Variables of the shared .env.test that only the Rust suite reads. */ -const RUST_SUITE_ONLY = [ - 'LIBID_TEST_PUBLIC_ORIGIN', - 'LIBID_TEST_X_REDIRECT_URI', - 'LIBID_TEST_GOOGLE_REDIRECT_URI', - 'X_TEST_ALICE_EMAIL', - 'X_TEST_ALICE_PASSWORD', - 'GOOGLE_TEST_ALICE_PASSWORD', -] +export const env = (name: string) => process.env[name] || undefined -export type { Lookup } from '../paths.ts' +const invoked = () => process.env.INIT_CWD ?? process.cwd() +/** The file LIBID_TEST_ENV_FILE names, relative to where pnpm was invoked. */ +const envFile = () => env('LIBID_TEST_ENV_FILE') && resolve(invoked(), env('LIBID_TEST_ENV_FILE')!) -const environment: Lookup = (name) => process.env[name] - -/** Exported variables an env file does not override even when set empty. */ -const EMPTY_WINS = ['LIBID_LIVE_PLATFORMS'] - -/** - * Read the settings file into the environment: an exported value wins; an - * empty one counts as absent, except for EMPTY_WINS. Returns the file's keys. - */ -export function loadEnvFile(file: string): string[] { - const values = parseEnv(readFileSync(file, 'utf8')) - for (const [name, value] of Object.entries(values)) { - const exported = process.env[name] - if (exported === undefined || (exported === '' && !EMPTY_WINS.includes(name))) - process.env[name] = value - } - return Object.keys(values) +/** Read the env file, if one is named, into the environment; exported values win. */ +export function loadEnvFile() { + const file = envFile() + if (!file) return + for (const [name, value] of Object.entries(parseEnv(readFileSync(file, 'utf8')))) + process.env[name] ??= value } -export const envFile = (get: Lookup = environment) => envFileOf(get) - -export interface Tooling { - /** LIBID_LIVE_PLATFORMS, trimmed and checked, or every platform when it is unset. */ - platforms: readonly PlatformId[] - /** A missing setting fails the run instead of skipping its test. */ - requireLive: boolean - proxy?: string - /** LIBID_STACK_LOGS, resolved as a path setting. */ - stackLogs?: string - /** Show the browser. */ - headed: boolean - /** A CI run: no traces or videos, the stack's output kept off the job log. */ - ci: boolean +/** A path setting: relative to the env file, else to where pnpm was invoked. */ +export const settingPath = (path: string) => { + const file = envFile() + return resolve(file ? dirname(file) : invoked(), path) } -/** A switch: on for `1` or `true`, in any case; off for anything else. */ -const flag = (get: Lookup, name: string) => /^(1|true)$/i.test(nonEmpty(get, name) ?? '') +export const requireLive = () => /^(1|true)$/i.test(env('LIBID_REQUIRE_LIVE') ?? '') -export function tooling(get: Lookup = environment): Tooling { - // Set but empty selects no platform; only an unset variable means all. - const listed = get('LIBID_LIVE_PLATFORMS') +/** LIBID_LIVE_PLATFORMS, comma-separated, or every platform when it is unset. */ +export function livePlatforms(): PlatformId[] { + const listed = process.env.LIBID_LIVE_PLATFORMS + if (listed === undefined) return PLATFORMS const names = listed - ?.split(',') + .split(',') .map((name) => name.trim()) .filter(Boolean) - const unknown = names?.filter((name) => !PLATFORMS.includes(name as PlatformId)) ?? [] + const unknown = names.filter((name) => !PLATFORMS.includes(name as PlatformId)) if (unknown.length) throw new Error(`LIBID_LIVE_PLATFORMS names ${unknown.join(', ')}; use ${PLATFORMS.join(', ')}`) - return { - platforms: (names as PlatformId[] | undefined) ?? PLATFORMS, - requireLive: flag(get, 'LIBID_REQUIRE_LIVE'), - proxy: nonEmpty(get, 'LIBID_TEST_PROXY'), - stackLogs: settingPath(get, nonEmpty(get, 'LIBID_STACK_LOGS')), - headed: flag(get, 'HEADED'), - ci: flag(get, 'CI'), - } + if (!names.length && requireLive()) + throw new Error('LIBID_REQUIRE_LIVE is set but LIBID_LIVE_PLATFORMS selects no platform') + return names as PlatformId[] } -/** The selected platforms whose settings are all present: the ones whose test runs. */ -export const runnablePlatforms = (get: Lookup = environment) => - tooling(get).platforms.filter((platform) => - PLATFORM_SECTIONS[platform].every((section) => !missing(section, get).length), - ) - -/** The accounts' and sessions' variables: every one outside the tooling section. */ -export const ACCOUNT_VARIABLES = VARIABLES.filter(([, section]) => section !== 'tooling').map( - ([name]) => name, -) +/** The variables `platform` lacks. */ +export const missing = (platform: PlatformId) => + PLATFORM_VARIABLES[platform] + .filter((names) => !names.split('|').some(env)) + .map((names) => names.replace('|', ' or ')) -/** Whether this run proves `platform`; one left out is skipped, and reported as skipped. */ -export const isLive = (platform: PlatformId) => tooling().platforms.includes(platform) +/** The selected platforms whose tests run: those with every variable set. */ +export const runnablePlatforms = () => livePlatforms().filter((p) => !missing(p).length) -/** The required variables of `section` that `get` finds absent; `A (or B)` where B stands in. */ -export function missing(section: Section, get: Lookup = environment): string[] { - return VARIABLES.filter( - ([name, of, need]) => of === section && need === 'required' && !nonEmpty(get, name), +/** Under LIBID_REQUIRE_LIVE, fail once, naming every variable a selected platform lacks. */ +export function checkSettings() { + const absent = livePlatforms().flatMap((p) => + missing(p).length ? [`${p}: ${missing(p).join(', ')}`] : [], ) - .map(([name]) => { - const standIn = VARIABLES.find( - ([, , need]) => typeof need === 'object' && need.insteadOf === name, - )?.[0] - if (!standIn) return name - return nonEmpty(get, standIn) ? undefined : `${name} (or ${standIn})` - }) - .filter((name): name is string => name !== undefined) -} - -/** - * Check every selected platform's sections at once: with LIBID_REQUIRE_LIVE, - * one error names every absent variable by platform. - */ -export function checkSettings(get: Lookup = environment) { - const { platforms, requireLive } = tooling(get) - if (requireLive && !platforms.length) - throw new Error('LIBID_REQUIRE_LIVE is set but LIBID_LIVE_PLATFORMS selects no platform') - if (!requireLive) return - const absent = platforms.flatMap((platform) => { - const names = PLATFORM_SECTIONS[platform].flatMap((section) => missing(section, get)) - return names.length ? [`${platform} needs ${names.join(', ')}`] : [] - }) - if (absent.length) + if (requireLive() && absent.length) throw new Error( - `The live tests are missing settings: ${absent.join('; ')}. Put them in the file LIBID_TEST_ENV_FILE names, or export them; in CI they are repository secrets passed to the job (.github/workflows/live-e2e.yml).`, + `Missing live test settings (${absent.join('; ')}). Put them in the file LIBID_TEST_ENV_FILE names, or export them.`, ) } - -/** Keys of the settings file that look like settings but neither suite reads: likely typos. */ -export function unknownKeys(keys: readonly string[]): string[] { - return keys.filter( - (key) => - (/^[A-Z]+_TEST_ALICE_/.test(key) || key.startsWith('LIBID_')) && - !VARIABLES.some(([name]) => name === key) && - !RUST_SUITE_ONLY.includes(key), - ) -} - -/** The section's values, or undefined when one it requires is absent. */ -function read(section: Section, get: Lookup, value: () => T): T | undefined { - return missing(section, get).length ? undefined : value() -} - -export interface GitHubAccount { - username: string - password: string - /** The base32 key of the account's authenticator app. */ - totpSecret: string -} - -export const githubAccount = (get: Lookup = environment) => - read('github-account', get, () => ({ - username: nonEmpty(get, 'GH_TEST_ALICE_USERNAME')!, - password: nonEmpty(get, 'GH_TEST_ALICE_PASSWORD')!, - totpSecret: nonEmpty(get, 'GH_TEST_ALICE_TOTP_SECRET')!, - })) - -/** The variables whose values the failure-output scan looks for. */ -export const SCANNED = [ - 'GH_TEST_ALICE_PASSWORD', - 'GH_TEST_ALICE_TOTP_SECRET', - 'X_TEST_ALICE_COOKIES', - 'X_TEST_ALICE_COOKIES_FILE', - 'GOOGLE_TEST_ALICE_COOKIES', - 'GOOGLE_TEST_ALICE_COOKIES_FILE', -] as const - -export const xAccount = (get: Lookup = environment) => - read('x-account', get, () => ({ username: nonEmpty(get, 'X_TEST_ALICE_USERNAME')! })) - -export const googleAccount = (get: Lookup = environment) => - read('google-account', get, () => ({ email: nonEmpty(get, 'GOOGLE_TEST_ALICE_EMAIL')! })) - -/** A saved session: base64 of the JSON cookie list, or a path to that JSON. */ -export type SavedSession = { encoded: string } | { path: string } - -function savedSession(prefix: string, get: Lookup): SavedSession | undefined { - const encoded = nonEmpty(get, `${prefix}_COOKIES`) - if (encoded) return { encoded } - const file = nonEmpty(get, `${prefix}_COOKIES_FILE`) - if (!file) return undefined - return { path: settingPath(get, file)! } -} - -export const xSession = (get: Lookup = environment) => savedSession('X_TEST_ALICE', get) - -export const googleSession = (get: Lookup = environment) => savedSession('GOOGLE_TEST_ALICE', get) diff --git a/ts/apps/dev/e2e/x.ts b/ts/apps/dev/e2e/x.ts index 401c6bd4..6885a759 100644 --- a/ts/apps/dev/e2e/x.ts +++ b/ts/apps/dev/e2e/x.ts @@ -1,5 +1,5 @@ import type { BrowserContext, Page } from '@playwright/test' -import { clickFound, drivePopup, evaluateWithin } from './popup.ts' +import { clickFound, drivePopup } from './popup.ts' import { onX, restoreSession, type Stored } from './session.ts' /** Put the saved session on both hosts X answers on, as the Rust suite does. */ @@ -26,9 +26,6 @@ const FIND_CONSENT = `[...document.querySelectorAll("[data-testid='OAuth_Consent (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)` -/** How long X may hold the account at /account/access, as the Rust suite waits. */ -const ACCOUNT_ACCESS_GRACE = 30_000 - /** * Approve the OAuth app on X in `popup`, signed in by the saved session, until * X returns it to Bridge's callback. A port of libid-server-rs' ceremony-tests/src/browser/x.rs @@ -38,7 +35,6 @@ const ACCOUNT_ACCESS_GRACE = 30_000 export async function authorizeOnX(popup: Page) { let consentedAt = 0 let challengedAt: number | undefined - let heldSince: number | undefined await drivePopup(popup, { name: 'X', onHost: (url) => onX(url.hostname), @@ -53,18 +49,10 @@ export async function authorizeOnX(popup: Page) { ) } else if (url.pathname.startsWith('/i/oauth2/authorize')) { challengedAt = undefined - await evaluateWithin(popup, ACCEPT_COOKIES, false) + await popup.evaluate(ACCEPT_COOKIES).catch(() => {}) if (Date.now() - consentedAt > 10_000 && (await clickFound(popup, FIND_CONSENT))) consentedAt = Date.now() - } else if (url.pathname.startsWith('/account/access')) { - // X holding the account: locked, or asking a person to verify it. - heldSince ??= Date.now() - if (Date.now() - heldSince > ACCOUNT_ACCESS_GRACE) - throw new Error( - 'X holds the account at /account/access (locked or asking for verification): clear it in a browser, then renew the saved session', - ) - } else if (text.includes('something went wrong')) - throw new Error(`X answered an error page on ${url.pathname}`) + } }, }) } diff --git a/ts/apps/dev/local.test.ts b/ts/apps/dev/local.test.ts deleted file mode 100644 index 40e3eda5..00000000 --- a/ts/apps/dev/local.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -// local.ts against the files that repeat its origins. -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { test } from 'node:test' -import { APP_ORIGIN, BRIDGE, NOTARY } from './local.ts' - -test("Bridge admits the app's origin", () => { - const config = readFileSync(new URL('./bridge-config.toml', import.meta.url), 'utf8') - const listed = /^allowed_app_origins\s*=\s*\[([^\]]*)\]/m.exec(config)?.[1] - assert.ok(listed, 'bridge-config.toml has allowed_app_origins') - const origins = [...listed.matchAll(/"([^"]*)"/g)].map((match) => match[1]) - assert.ok(origins.includes(APP_ORIGIN), `allowed_app_origins lacks ${APP_ORIGIN}`) -}) - -test("the README's service table and links name local.ts' origins", () => { - const readme = readFileSync(new URL('./README.md', import.meta.url), 'utf8') - for (const line of [ - `| Application | ${APP_ORIGIN} |`, - `| OAuth Bridge | ${BRIDGE} |`, - `| Notary HTTP/WebSocket | ${NOTARY} |`, - `Open **${APP_ORIGIN}**`, - `**\`${BRIDGE}/auth/callback\`**`, - ]) - assert.ok(readme.includes(line), `the README says ${line}`) -}) diff --git a/ts/apps/dev/package.json b/ts/apps/dev/package.json index 7dee6ab0..8bf70994 100644 --- a/ts/apps/dev/package.json +++ b/ts/apps/dev/package.json @@ -13,7 +13,7 @@ "build:deps": "pnpm --filter '@libid/dev^...' build", "build": "vite build", "typecheck": "tsc --noEmit", - "test": "node --test local.test.ts e2e/*.test.ts", + "test": "node --test e2e/*.test.ts", "test:e2e": "playwright test", "test:live": "playwright test -c playwright.live.config.ts" }, diff --git a/ts/apps/dev/paths.ts b/ts/apps/dev/paths.ts deleted file mode 100644 index 298f1137..00000000 --- a/ts/apps/dev/paths.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Where a path setting points, for the live tests' settings and services.ts -// alike: a relative path is the settings file's neighbour when -// LIBID_TEST_ENV_FILE names one, else relative to where pnpm was invoked. -import { dirname, resolve } from 'node:path' - -/** The value of a name as set, empty included; undefined when unset. */ -export type Lookup = (name: string) => string | undefined - -/** A value, an empty one counting as absent. */ -export const nonEmpty = (get: Lookup, name: string) => get(name) || undefined - -/** Where pnpm was invoked. */ -const invoked = (get: Lookup) => nonEmpty(get, 'INIT_CWD') ?? process.cwd() - -/** The settings file LIBID_TEST_ENV_FILE names, relative to where pnpm was invoked. */ -export function envFile(get: Lookup): string | undefined { - const file = nonEmpty(get, 'LIBID_TEST_ENV_FILE') - return file && resolve(invoked(get), file) -} - -/** The path a setting names, resolved; undefined when it is unset or empty. */ -export function settingPath(get: Lookup, path: string | undefined): string | undefined { - if (!path) return undefined - const settingsFile = envFile(get) - return resolve(settingsFile ? dirname(settingsFile) : invoked(get), path) -} diff --git a/ts/apps/dev/playwright.live.config.ts b/ts/apps/dev/playwright.live.config.ts index 062f3faa..d082e0d3 100644 --- a/ts/apps/dev/playwright.live.config.ts +++ b/ts/apps/dev/playwright.live.config.ts @@ -1,70 +1,54 @@ import { defineConfig, devices } from '@playwright/test' -import { ACTION, NAVIGATION, STACK_START, STACK_STOP, TEST } from './e2e/budgets.ts' -import { preflight } from './e2e/preflight.ts' -import { - ACCOUNT_VARIABLES, - envFile, - loadEnvFile, - runnablePlatforms, - tooling, - unknownKeys, -} from './e2e/settings.ts' +import { checkSettings, env, loadEnvFile, runnablePlatforms, settingPath } from './e2e/settings.ts' import { APP_ORIGIN } from './local.ts' -// Test secrets from a dotenv file when LIBID_TEST_ENV_FILE names one; values -// set in the environment win. Nothing is printed. The runner process -// checks them once; its workers load this file too. -const runner = process.env.TEST_WORKER_INDEX === undefined -const file = envFile() -if (file) { - const typos = unknownKeys(loadEnvFile(file)) - if (runner && typos.length) - console.warn(`${file}: no live test reads ${typos.join(', ')}; check the spelling.`) -} -if (runner) preflight() -const { proxy, headed, ci, stackLogs } = tooling() -// The services' output carries what the job log must not: CI writes it to a -// file it scans before upload. -if (ci && !stackLogs) - throw new Error("CI runs need LIBID_STACK_LOGS: a file for the services' output") +// Test secrets from the file LIBID_TEST_ENV_FILE names; exported values win. +// The runner process checks them once; its workers load this file too. +loadEnvFile() +if (process.env.TEST_WORKER_INDEX === undefined) checkSettings() +const stackLogs = env('LIBID_STACK_LOGS') && settingPath(env('LIBID_STACK_LOGS')!) +const proxy = env('LIBID_TEST_PROXY') +const ci = !!env('CI') + +/** + * The stack's environment: only what the build, Docker and services.ts need. + * Playwright merges in its own environment, so every other variable, the test + * accounts' secrets among them, is set empty. + */ +const KEPT = /^(PATH|HOME|USER|TMPDIR|LANG|CI|PNPM_HOME|XDG_\w+|DOCKER_\w+)$/ +const stackEnv = Object.fromEntries( + Object.entries(process.env).map(([name, value]) => [name, KEPT.test(name) ? (value ?? '') : '']), +) /** Live: real platforms, real notary, real Bridge; no chain. */ export default defineConfig({ testDir: 'e2e', testMatch: '*.live.spec.ts', workers: 1, - // Each test's own budgets, summed in e2e/budgets.ts. - timeout: TEST, + // A test: page load and Ready (2 min), the popup (1 min), its driver (3 min) + // and the outcome (8 min), with margin. + timeout: 15 * 60_000, reporter: [['list']], - // One command: start the services and the app, test, then stop both. The - // run never reuses a running stack: services.ts refuses taken ports. - // No test that will run: nothing to start. + // Start the services and the app only when a test will run, then stop both. webServer: runnablePlatforms().length ? { // exec: Playwright's SIGTERM reaches services.ts, which brings the stack // down. With LIBID_STACK_LOGS, the build's output goes there too. command: `pnpm build:deps${stackLogs ? ' >>"$LIBID_STACK_LOGS" 2>&1' : ''} && exec node services.ts --app`, - // The stack gets none of the test accounts' secrets; the scan stays a backstop. - env: { - ...(process.env as Record), - ...Object.fromEntries(ACCOUNT_VARIABLES.map((name) => [name, ''])), - ...(stackLogs && { LIBID_STACK_LOGS: stackLogs }), - }, + env: { ...stackEnv, ...(stackLogs && { LIBID_STACK_LOGS: stackLogs }) }, url: APP_ORIGIN, reuseExistingServer: false, - timeout: STACK_START, - gracefulShutdown: { signal: 'SIGTERM', timeout: STACK_STOP }, - // Under CI only services.ts' own messages reach the job log. - stdout: ci ? 'ignore' : 'pipe', + timeout: 10 * 60_000, + gracefulShutdown: { signal: 'SIGTERM', timeout: 2 * 60_000 }, } : undefined, use: { ...devices['Desktop Chrome'], baseURL: APP_ORIGIN, - headless: !headed, + headless: !env('HEADED'), // A hang ends on the action that hangs, not on the test's timeout. - actionTimeout: ACTION, - navigationTimeout: NAVIGATION, + actionTimeout: 30_000, + navigationTimeout: 60_000, // Automation X and Google can see makes them challenge; see e2e/person.ts. launchOptions: { args: ['--disable-blink-features=AutomationControlled'] }, // LIBID_TEST_PROXY routes the browser's traffic, loopback aside, through a diff --git a/ts/apps/dev/services.ts b/ts/apps/dev/services.ts index 17e214f4..2e60933e 100644 --- a/ts/apps/dev/services.ts +++ b/ts/apps/dev/services.ts @@ -1,84 +1,56 @@ // Start the real Bridge, notary and emitted CCDP; no OAuth mocks. -// LIBID_STACK_LOGS names a file that takes every child's output, containers -// included, appended; a relative path resolves as paths.ts says. +// LIBID_STACK_LOGS names a file every child's output, containers included, +// is appended to; a relative path is relative to where pnpm was invoked. import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { openSync } from 'node:fs' import { connect } from 'node:net' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import { createServer as createViteServer, type ViteDevServer } from 'vite' -import { APP_ORIGIN, BRIDGE, NOTARY } from './local.ts' -import { settingPath } from './paths.ts' +import { APP_ORIGIN, BRIDGE } from './local.ts' const root = fileURLToPath(new URL('.', import.meta.url)) // Distinct Compose ownership for each checkout. No shared container names. const project = `libid-dev-${createHash('sha256').update(root).digest('hex').slice(0, 12)}` const composeArgs = ['compose', '-p', project, '-f', join(root, 'compose.yaml')] -// --project: print this checkout's Compose project, for a cleanup to stop it. -if (process.argv.includes('--project')) { - console.info(project) - process.exit(0) -} -const logPath = settingPath((name) => process.env[name], process.env.LIBID_STACK_LOGS) -const logs = logPath ? openSync(logPath, 'a') : undefined +const logPath = process.env.LIBID_STACK_LOGS +const logs = logPath ? openSync(resolve(process.env.INIT_CWD ?? root, logPath), 'a') : undefined const stdio = logs === undefined ? 'inherit' : (['ignore', logs, logs] as ['ignore', number, number]) /** Whether something on this host accepts connections on `port`. */ const listening = (port: number) => new Promise((resolve) => { const socket = connect({ port, host: '127.0.0.1', timeout: 1000 }) - socket.once('connect', () => { + const done = (open: boolean) => { socket.destroy() - resolve(true) - }) - socket.once('timeout', () => { - socket.destroy() - resolve(false) - }) - socket.once('error', () => resolve(false)) + resolve(open) + } + socket.once('connect', () => done(true)) + socket.once('timeout', () => done(false)) + socket.once('error', () => done(false)) }) -// Refuse to start beside another stack on compose.yaml's ports: readiness could pass against it. +// Refuse to start beside another stack: readiness could pass against it. if (!process.argv.includes('--ccdp')) { let ports: number[] - let own: string try { - own = execFileSync('docker', [...composeArgs, 'ps', '-q'], { encoding: 'utf8' }).trim() const config = JSON.parse( execFileSync('docker', [...composeArgs, 'config', '--format', 'json'], { encoding: 'utf8' }), ) as { services: Record } - // A range or an unpublished port is no single port to probe. ports = Object.values(config.services).flatMap((service) => - (service.ports ?? []).flatMap(({ published }) => - published && /^\d+$/.test(published) ? [Number(published)] : [], - ), + (service.ports ?? []).map(({ published }) => Number(published)).filter(Boolean), ) } catch { console.error('Could not run Docker Compose. Install Docker with Compose and start its engine.') process.exit(1) } - // Which service answers on each is checked once the stack is up. - for (const [name, origin] of Object.entries({ BRIDGE, NOTARY })) { - const port = Number(new URL(origin).port) - if (!ports.includes(port)) { - console.error(`local.ts's ${name} port ${port} is not one compose.yaml publishes.`) - process.exit(1) - } - } - // The app's own port, when this process serves it. if (process.argv.includes('--app')) ports.push(Number(new URL(APP_ORIGIN).port)) const open = await Promise.all(ports.map(listening)) const taken = ports.filter((_, index) => open[index]) - if (taken.length && own) { - console.error( - `This checkout's stack already runs (Compose project ${project}): stop that \`pnpm dev\`, or run \`docker compose -p ${project} down\`.`, - ) - process.exit(1) - } if (taken.length) { console.error( - `Already in use on 127.0.0.1: port ${taken.join(', ')}. Stop what holds it (see docker ps) and retry.`, + `Already in use on 127.0.0.1: port ${taken.join(', ')}. Stop what holds it (a running \`pnpm dev\`? see docker ps) and retry.`, ) process.exit(1) } @@ -159,26 +131,6 @@ if (process.argv.includes('--app')) { } await delay(500) } - // Readiness above proves BRIDGE is the Bridge; only the notary serves /healthcheck. - if (!stopping) { - const notary = await fetch( - new URL('/healthcheck', NOTARY.replace('localhost', '127.0.0.1')), - { - signal: AbortSignal.timeout(2000), - }, - ) - .then(async (response) => { - await response.body?.cancel() - return response.status - }) - .catch(() => 0) - if (notary !== 200) { - console.error( - `local.ts's NOTARY ${NOTARY} does not answer as the notary (GET /healthcheck).`, - ) - stop(1) - } - } if (!stopping) { frontend = await createViteServer({ configFile: join(root, 'vite.config.ts') }) if (stopping) await frontend.close() From a9adc8c3448ab014b6b9f4d54323998332d6e2c6 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 09:38:45 +0300 Subject: [PATCH 096/103] ci(live-e2e): simplify the live jobs Two jobs with plain steps: GitHub and X in one, Google through the SSM proxy in the other, each given only its platforms' secrets and its own concurrency group, so neither cancels the other's pending run. On a failure a job stops any stack left running, prints the end of the stack log to the job log, where GitHub masks secrets and the stack holds none, and uploads the screenshots for 7 days. The failure-output action and its scan are removed. Each job states its timeout arithmetic. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/actions/live-e2e-output/action.yml | 39 ------ .github/workflows/live-e2e.yml | 138 ++++++++------------- 2 files changed, 51 insertions(+), 126 deletions(-) delete mode 100644 .github/actions/live-e2e-output/action.yml diff --git a/.github/actions/live-e2e-output/action.yml b/.github/actions/live-e2e-output/action.yml deleted file mode 100644 index 5afadf37..00000000 --- a/.github/actions/live-e2e-output/action.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Keep the live end-to-end failure output -description: >- - Stop any dev stack left running, scan its log (LIBID_STACK_LOGS) for the - test accounts' secrets, then upload the PNG screenshots and the scanned - log. The caller passes the secrets to look for as env and runs this only - on failure. - -# Every third-party action is pinned by commit SHA, with the tag in a -# comment, so a moved tag cannot change what executes. - -inputs: - name: - description: The artifact's name. - required: true - -runs: - using: composite - steps: - # 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. Stopping - # it is best effort; a secret in the log, or a scan that cannot run, - # fails this step, and nothing goes up. - - name: Check the stack log for secrets - shell: bash - run: | - project=$(node ts/apps/dev/services.ts --project) && - docker compose -p "$project" down --volumes || true - if [ -f "$LIBID_STACK_LOGS" ]; then cp "$LIBID_STACK_LOGS" "$RUNNER_TEMP/stack-scanned.log"; fi - node ts/apps/dev/e2e/scan-secrets.ts "$RUNNER_TEMP/stack-scanned.log" - - - name: Keep the screenshots and the stack log - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: ${{ inputs.name }} - path: | - ts/apps/dev/test-results/**/*.png - ${{ runner.temp }}/stack-scanned.log - retention-days: 14 - if-no-files-found: ignore diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 50380eb9..623832c0 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -1,16 +1,12 @@ name: Live end-to-end -# Proves a real platform identity from the browser UI through the CCDP -# ceremony: real OAuth sign-in, the real notary and Bridge, and in-browser -# proving. No chain. 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. +# Proves real platform identities from the dev app's UI (ts/apps/dev/README.md). +# Not a required check: it depends on the platforms, their accounts and the +# network. Third-party actions are pinned by commit SHA, the tag in a comment. on: pull_request: - # What a live ceremony runs: the dev app, the packages it bundles and - # their build (brand/ styles the ceremony), the workspace setup, and CI. + # What a live ceremony runs: the dev app, its packages and their build. paths: - "ts/apps/dev/**" - "ts/packages/ceremony/**" @@ -24,48 +20,35 @@ on: - "ts/tsconfig.base.json" - "brand/**" - ".github/workflows/live-e2e.yml" - - ".github/actions/live-e2e-output/**" - ".github/actions/setup-ts/**" workflow_dispatch: schedule: - cron: "23 3 * * *" -# One run per pull request, a newer push cancelling its older run; -# scheduled and dispatched runs share a group and are not cancelled. The -# jobs that sign in also share one group across every run (live-e2e-accounts), -# so no two runs use the test accounts at once: two sign-ins could submit -# the same TOTP code in one 30-second window. GitHub keeps one job pending -# per group, so a newer pending job replaces an older pending one. -concurrency: - 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 env: - # Where services.ts writes the stack's output; the failure-output action - # scans and uploads it. + # Where services.ts writes the stack's output; a failed job prints its end. LIBID_STACK_LOGS: /tmp/libid-live-stack.log + # A missing secret fails the run instead of skipping the test. + LIBID_REQUIRE_LIVE: "1" jobs: ceremony: - name: Live ceremonies - # A pull request from a fork or from Dependabot gets no secrets, so it - # does not run. + name: GitHub and X ceremonies (live) + # A pull request from a fork or from Dependabot gets no secrets. if: >- github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') runs-on: ubuntu-latest + # One run at a time per account: two could submit one TOTP code twice. concurrency: - group: live-e2e-accounts + group: live-e2e-github-x cancel-in-progress: false - # The test step's budget plus setup, the secret scan and the upload. - timeout-minutes: 75 - env: - # A missing secret fails the run instead of skipping the test. - LIBID_REQUIRE_LIVE: "1" + # Test step 45 (stack 10 + two tests of 15 + stop 2 = 42) + setup 15. + timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -76,55 +59,39 @@ jobs: - name: Install Chromium run: pnpm -C ts --filter @libid/dev exec playwright install --with-deps chromium - # The platforms are the repository variable LIBID_LIVE_PLATFORMS, else - # GitHub and X. Google is left out by default: it has challenged a saved - # session arriving from a runner's address ("Verify it's you"), which no - # automation passes. The google-ssm job runs it through a proxy. - # Its own timeout, so a run that hangs still reaches the step below: the - # stack's 10-minute start, up to three tests of 16 minutes (all the - # variable can select), and 2 minutes to stop the stack - # (ts/apps/dev/e2e/budgets.ts): 60 minutes, with room to spare. - - name: Prove identities end to end + - name: Prove GitHub and X identities end to end id: test - timeout-minutes: 65 + timeout-minutes: 45 env: - LIBID_LIVE_PLATFORMS: ${{ vars.LIBID_LIVE_PLATFORMS || 'github,x' }} + LIBID_LIVE_PLATFORMS: github,x 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 `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 }} - GOOGLE_TEST_ALICE_EMAIL: ${{ secrets.GOOGLE_TEST_ALICE_EMAIL }} - # The saved Google session: base64 of a Playwright storage state or - # of `ceremony export google`. - GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} run: pnpm -C ts/apps/dev test:live - # The artifact is public. The stack's log, which the job log does not - # carry (playwright.live.config.ts), goes up only when the scan finds none - # of the test accounts' secrets in it. Screenshots go up unscanned: the - # pages driven never display the password, the TOTP secret or a session - # cookie. A timed-out test step counts too; a cancelled run uploads nothing. - - name: Keep the failure output - if: ${{ !cancelled() && (steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} - uses: ./.github/actions/live-e2e-output + # The stack gets no secret (playwright.live.config.ts); GitHub masks them anyway. + - name: Stop the stack and show its log + if: ${{ !cancelled() && steps.test.outcome != 'success' }} + run: | + docker compose ls -q | grep '^libid-dev-' | xargs -r -I{} docker compose -p {} down || true + tail -n 300 "$LIBID_STACK_LOGS" || true + + - name: Keep the screenshots + if: ${{ !cancelled() && steps.test.outcome != 'success' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: live-e2e - env: - 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 }} - GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} + name: live-e2e-github-x + path: ts/apps/dev/test-results/**/*.png + retention-days: 7 + if-no-files-found: ignore google-ssm: name: Google ceremony through an SSM-reached proxy (live) - # Runs while the repository variable LIBID_GOOGLE_RUNNER is `ssm`, on a - # dispatch or the schedule. The proxy is an EC2 instance with no inbound - # port: LIBID_GOOGLE_PROXY_ROLE may only open an SSM port-forward to - # LIBID_GOOGLE_PROXY_INSTANCE. + # Google challenges a session from a runner's address, so the browser goes + # through an EC2 proxy LIBID_GOOGLE_PROXY_ROLE reaches only by SSM. + # Runs while LIBID_GOOGLE_RUNNER is `ssm`. if: >- vars.LIBID_GOOGLE_RUNNER == 'ssm' && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || @@ -133,15 +100,13 @@ jobs: github.actor != 'dependabot[bot]')) runs-on: ubuntu-latest concurrency: - group: live-e2e-accounts + group: live-e2e-google cancel-in-progress: false - # The test step's budget plus setup, the proxy, the secret scan and the upload. - timeout-minutes: 50 + # Test step 30 (stack 10 + one test of 15 + stop 2 = 27) + setup and proxy 15. + timeout-minutes: 45 permissions: contents: read id-token: write - env: - LIBID_REQUIRE_LIVE: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -157,15 +122,11 @@ jobs: role-to-assume: ${{ vars.LIBID_GOOGLE_PROXY_ROLE }} aws-region: eu-central-1 - # The port-forward runs for the rest of the job; the step fails unless - # the proxy answers, and prints the address Google will see. + # The port-forward runs for the rest of the job; the step fails unless the proxy answers. - name: Reach the Google proxy over SSM env: INSTANCE: ${{ vars.LIBID_GOOGLE_PROXY_INSTANCE }} - # The runner image ships session-manager-plugin. run: | - command -v session-manager-plugin >/dev/null || - { echo "::error::session-manager-plugin is not on this runner image"; exit 1; } aws ssm start-session --target "$INSTANCE" \ --document-name AWS-StartPortForwardingSession \ --parameters portNumber=3128,localPortNumber=3128 >"$RUNNER_TEMP/port-forward.log" 2>&1 & @@ -175,12 +136,9 @@ jobs: done echo "Google sees ${ip:?the proxy did not answer; see $RUNNER_TEMP/port-forward.log}" - # Its own timeout, so a run that hangs still reaches the step below: the - # stack's 10-minute start, one test of 16 minutes, and 2 minutes to stop - # the stack (ts/apps/dev/e2e/budgets.ts): 28 minutes, with room to spare. - name: Prove a Google identity end to end id: test - timeout-minutes: 35 + timeout-minutes: 30 env: LIBID_LIVE_PLATFORMS: google LIBID_TEST_PROXY: http://127.0.0.1:3128 @@ -188,11 +146,17 @@ jobs: GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} run: pnpm -C ts/apps/dev test:live - # As in the ceremony job. - - name: Keep the failure output - if: ${{ !cancelled() && (steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} - uses: ./.github/actions/live-e2e-output + - name: Stop the stack and show its log + if: ${{ !cancelled() && steps.test.outcome != 'success' }} + run: | + docker compose ls -q | grep '^libid-dev-' | xargs -r -I{} docker compose -p {} down || true + tail -n 300 "$LIBID_STACK_LOGS" || true + + - name: Keep the screenshots + if: ${{ !cancelled() && steps.test.outcome != 'success' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: live-e2e-google-ssm - env: - GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} + name: live-e2e-google + path: ts/apps/dev/test-results/**/*.png + retention-days: 7 + if-no-files-found: ignore From 807e3bf2a3fca7005562daaef4b8bc34ee7329c4 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 09:38:45 +0300 Subject: [PATCH 097/103] docs(dev): shorten the live tests section Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/README.md | 61 ++++++++++++++++++++----------------------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/ts/apps/dev/README.md b/ts/apps/dev/README.md index d879002a..7e6ca3c0 100644 --- a/ts/apps/dev/README.md +++ b/ts/apps/dev/README.md @@ -37,7 +37,7 @@ Edit configuration directly where it is used: - [compose.yaml](compose.yaml): services. - [bridge-config.toml](bridge-config.toml): Bridge configuration and development OAuth credentials. -- [local.ts](local.ts): the app origin (frontend port), and the Bridge and notary URLs; the table above repeats them, and `pnpm --filter @libid/dev test` checks it. +- [local.ts](local.ts): the app origin (frontend port), and the Bridge and notary URLs. - [src/app.ts](src/app.ts): the local ledger fixture; Bridge configuration selects CCDP. The setup includes shared development OAuth credentials and a public development @@ -108,40 +108,35 @@ without fallback. Direct runs have no fallback row. ## Live tests -The live suite proves real GitHub, X and Google accounts from this app's UI, -with the services above. It starts its own `pnpm dev` and stops it after, so -the ports above and the app's must be free: stop a running `pnpm dev` first. +The live suite proves real GitHub, X and Google accounts from this app's UI. +It starts its own `pnpm dev` and stops it after, so the ports above must be +free. ```sh LIBID_TEST_ENV_FILE=path/to/.env.test pnpm --filter @libid/dev test:live ``` -The variables, read through [e2e/settings.ts](e2e/settings.ts), carry the -names libid-server-rs' ceremony-tests uses, so one `.env.test` serves both -suites. With `LIBID_REQUIRE_LIVE`, the run checks every selected platform's -variables at once, then reads each selected platform's saved session and -TOTP key, before the stack starts; without it, a test lacking its variables -is skipped. A switch is on for `1` or `true`, in any case. Keep the env file -outside the repository, or name it `.env.test`, which git ignores. The path -rule: a relative path in a setting is relative to the env file, or, without -one, to where you run pnpm. Keys in the env file that look like settings but -that neither suite reads are reported as likely typos. - -| Variable | Section | Need | Use | -|---|---|---|---| -| `GH_TEST_ALICE_USERNAME` | github-account | required | The GitHub test account. | -| `GH_TEST_ALICE_PASSWORD` | github-account | required | Its password. | -| `GH_TEST_ALICE_TOTP_SECRET` | github-account | required | The base32 key of its authenticator app. | -| `X_TEST_ALICE_USERNAME` | x-account | required | The X test account's handle. | -| `X_TEST_ALICE_COOKIES` | x-session | required | Its saved session, base64 of the JSON cookie list. | -| `X_TEST_ALICE_COOKIES_FILE` | x-session | instead of `X_TEST_ALICE_COOKIES` | A path to that JSON (see the path rule above). | -| `GOOGLE_TEST_ALICE_EMAIL` | google-account | required | The Google test account. | -| `GOOGLE_TEST_ALICE_COOKIES` | google-session | required | Its saved session, as for X. | -| `GOOGLE_TEST_ALICE_COOKIES_FILE` | google-session | instead of `GOOGLE_TEST_ALICE_COOKIES` | A path to that JSON, as for X. | -| `LIBID_TEST_ENV_FILE` | tooling | optional | A dotenv file with the variables above, relative to where you run pnpm; exported values win, and an exported empty one counts as unset except for `LIBID_LIVE_PLATFORMS`. | -| `LIBID_LIVE_PLATFORMS` | tooling | optional | Comma-separated platforms to prove, such as `github,x`; all three when unset, none when set empty. In CI the repository variable selects; unset or empty there means `github,x`. | -| `LIBID_REQUIRE_LIVE` | tooling | optional | `1` or `true`: a missing variable or an empty platform list fails instead of skipping. | -| `LIBID_TEST_PROXY` | tooling | optional | A proxy for the browser's traffic, loopback aside. | -| `LIBID_STACK_LOGS` | tooling | optional | A file the build's and the services' output, containers included, is appended to instead of the terminal; a relative path follows the path rule above, in `pnpm dev` too. Required under CI. | -| `HEADED` | tooling | optional | `1` or `true`: show the browser. | -| `CI` | tooling | optional | `1` or `true`, as CI services set it: no traces or videos; the build's and the containers' output go only to `LIBID_STACK_LOGS`, and only services.ts' own messages reach the job log. | +The variables carry the names libid-server-rs' ceremony-tests uses, so one +`.env.test` serves both suites; keep it outside the repository, or name it +`.env.test`, which git ignores. A test whose variables are missing is skipped. + +| Variable | Platform | What | +|---|---|---| +| `GH_TEST_ALICE_USERNAME`, `GH_TEST_ALICE_PASSWORD` | GitHub | The test account. | +| `GH_TEST_ALICE_TOTP_SECRET` | GitHub | The base32 key of its authenticator app. | +| `X_TEST_ALICE_USERNAME` | X | The test account's handle. | +| `X_TEST_ALICE_COOKIES` or `X_TEST_ALICE_COOKIES_FILE` | X | A saved session: base64 of the JSON cookie list from `ceremony export x`, or a path to that JSON, relative to the env file. | +| `GOOGLE_TEST_ALICE_EMAIL` | Google | The test account. | +| `GOOGLE_TEST_ALICE_COOKIES` or `GOOGLE_TEST_ALICE_COOKIES_FILE` | Google | A saved session, as for X (`ceremony export google`). | +| `LIBID_TEST_ENV_FILE` | — | A dotenv file with the variables above; exported values win. | +| `LIBID_LIVE_PLATFORMS` | — | Comma-separated platforms to prove, such as `github,x`; all three when unset. | +| `LIBID_REQUIRE_LIVE` | — | `1` or `true`: a missing variable fails the run instead of skipping. | +| `LIBID_TEST_PROXY` | — | A proxy for the browser's traffic, loopback aside. | +| `LIBID_STACK_LOGS` | — | A file the build's and the services' output is appended to. | +| `HEADED` | — | Set: show the browser. | + +In CI, [live-e2e.yml](../../../.github/workflows/live-e2e.yml) runs GitHub and X +daily in one job, and Google in another through a proxy whose address Google +accepts. Each job gets only its own platforms' secrets. On a failure it prints +the end of the stack log to the job log, where GitHub masks the secrets, and +uploads the screenshots. From b0c3363190a034ec9dc4f0846f67ccf1cf8917af Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 09:49:01 +0300 Subject: [PATCH 098/103] refactor(dev): click with Playwright locators and one throttled helper The drivers' injected page scripts give way to locators: X's consent button by test id or role, its cookie banner by role (tried until it is accepted), Google's account row by data-identifier or its Continue or Allow button, and Google's login check by visible inputs. One throttledClick in popup.ts, at most a click every 5 s on the first visible match, replaces GitHub's, Google's and X's own timers. The app-failure check runs every 2 s. TOTP generation keeps only the options otpauth does not default, and the renew messages drop the branch name. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/github.ts | 34 +++++++++++++------------------ ts/apps/dev/e2e/google.ts | 28 ++++++++++--------------- ts/apps/dev/e2e/popup.ts | 43 ++++++++++++++++++--------------------- ts/apps/dev/e2e/x.ts | 39 +++++++++++++---------------------- 4 files changed, 59 insertions(+), 85 deletions(-) diff --git a/ts/apps/dev/e2e/github.ts b/ts/apps/dev/e2e/github.ts index 46dfdefb..a54bccae 100644 --- a/ts/apps/dev/e2e/github.ts +++ b/ts/apps/dev/e2e/github.ts @@ -1,8 +1,8 @@ import type { Page } from '@playwright/test' import * as OTPAuth from 'otpauth' -import { drivePopup, type Pause } from './popup.ts' +import { drivePopup, type Pause, throttledClick } from './popup.ts' -export interface GitHubAccount { +interface GitHubAccount { username: string password: string /** The base32 key of the account's authenticator app. */ @@ -13,19 +13,16 @@ export interface GitHubAccount { export function totp(secret: string, time = Date.now()): string { return new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(secret.replace(/\s/g, '').toUpperCase()), - algorithm: 'SHA1', - digits: 6, - period: 30, }).generate({ timestamp: time }) } /** What the login has done so far: the request a restart reopens, the login filled, the - * step whose code went, the last click (none again within 5 s), and back-off counts. */ + * step whose code went, the button clicks, and back-off counts. */ interface Progress { start?: string filled: boolean totpStep?: number - clickedAt: number + click: ReturnType rateLimited: number errored: number } @@ -43,13 +40,6 @@ async function backOff( await pause(step * count, `${what} ${count} times`) } -/** Click `target` unless a button was clicked in the last 5 s; the page may be leaving. */ -async function clickOnce(progress: Progress, target: ReturnType) { - if (Date.now() - progress.clickedAt < 5_000) return - progress.clickedAt = Date.now() - await target.click({ timeout: 5_000 }).catch(() => {}) -} - async function onLogin(popup: Page, account: GitHubAccount, progress: Progress) { if (progress.filled || !(await popup.locator('#login_field').count())) return progress.filled = true @@ -93,7 +83,7 @@ async function onGitHubPage( ) { 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(() => {}) + await popup.goto(progress.start).catch(() => {}) progress.filled = false progress.totpStep = undefined } else if (path === '/login' || path === '/session') { @@ -109,11 +99,10 @@ async function onGitHubPage( throw new Error('GitHub asked for device verification: the account has no TOTP method') // GitHub's periodic 2FA checkup, served in place of the page asked for. else if (text.includes('verify your two-factor authentication')) - await clickOnce(progress, popup.getByText('skip 2FA verification')) + await progress.click(popup.getByText('skip 2FA verification')) else if (path === '/login/oauth/authorize') { - const authorize = popup.locator("button[name='authorize'][value='1']") - if (await authorize.count()) await clickOnce(progress, authorize) - else if (text.includes('redirect_uri') || text.includes('be careful')) + const authorized = await progress.click(popup.locator("button[name='authorize'][value='1']")) + if (!authorized && (text.includes('redirect_uri') || text.includes('be careful'))) throw new Error( "GitHub refused the authorization request: the app's callback URL does not match", ) @@ -125,7 +114,12 @@ async function onGitHubPage( * Bridge's callback. A port of libid-server-rs' ceremony-tests/src/browser/github.rs. */ export async function authorizeOnGitHub(popup: Page, account: GitHubAccount) { - const progress: Progress = { filled: false, clickedAt: 0, rateLimited: 0, errored: 0 } + const progress: Progress = { + filled: false, + click: throttledClick(), + rateLimited: 0, + errored: 0, + } await drivePopup(popup, { name: 'GitHub', onHost: (url) => url.hostname === 'github.com', diff --git a/ts/apps/dev/e2e/google.ts b/ts/apps/dev/e2e/google.ts index ebf8b285..92f41fd0 100644 --- a/ts/apps/dev/e2e/google.ts +++ b/ts/apps/dev/e2e/google.ts @@ -1,5 +1,5 @@ import type { Page } from '@playwright/test' -import { clickFound, drivePopup } from './popup.ts' +import { drivePopup, throttledClick } from './popup.ts' /** Pages that no automation gets past: the session or the OAuth client needs a person. */ const REFUSED: [string, string][] = [ @@ -14,17 +14,6 @@ const REFUSED: [string, string][] = [ ['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. */ -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())); - return account || consent; -})()` - /** * Choose the account and consent on Google in `popup`, signed in by the saved * session, until Google returns it to Bridge's callback. A port of libid-server-rs' @@ -32,7 +21,7 @@ const findNext = (email: string) => `(() => { * the saved session has expired. */ export async function authorizeOnGoogle(popup: Page, email: string) { - let clickedAt = 0 + const click = throttledClick() await drivePopup(popup, { name: 'Google', onHost: (url) => url.hostname === 'accounts.google.com', @@ -41,12 +30,17 @@ export async function authorizeOnGoogle(popup: Page, email: string) { 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)) + const login = popup.locator('input[type=password],input[type=email],input[name=identifier]') + if (await login.filter({ visible: true }).count()) 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).', + 'Google asked to sign in: the saved session has expired. Renew it with `ceremony export google` (libid-server-rs).', ) - if (Date.now() - clickedAt > 3_000 && (await clickFound(popup, findNext(email)))) - clickedAt = Date.now() + // The account's row, else a Continue or Allow button. + await click( + popup + .locator(`[data-identifier="${email}" i]`) + .or(popup.getByRole('button', { name: /^(continue|allow)$/i })), + ) }, }) } diff --git a/ts/apps/dev/e2e/popup.ts b/ts/apps/dev/e2e/popup.ts index 77c9ee6c..195b2a88 100644 --- a/ts/apps/dev/e2e/popup.ts +++ b/ts/apps/dev/e2e/popup.ts @@ -1,5 +1,5 @@ import { setTimeout as sleep } from 'node:timers/promises' -import type { Page } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' import { BRIDGE } from '../local.ts' const BUDGET_MS = 180_000 @@ -10,7 +10,7 @@ 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 { +interface Driver { /** The platform's name, for the timeout message. */ name: string /** Whether `url` is one of the platform's own pages. */ @@ -55,11 +55,15 @@ export async function drivePopup(popup: Page, driver: Driver) { ) await sleep(ms) } + let checkedAt = 0 while (Date.now() < deadline) { if (hasReturned()) return - const failed = await failures.get(popup)?.() - if (failed) - throw new Error(`The app's run failed during the ${driver.name} authorization: ${failed}`) + if (Date.now() - checkedAt > 2_000) { + checkedAt = Date.now() + const failed = await failures.get(popup)?.() + if (failed) + throw new Error(`The app's run failed during the ${driver.name} authorization: ${failed}`) + } if (popup.isClosed()) throw new Error(`The popup closed before ${driver.name} returned it to Bridge`) const url = URL.parse(popup.url()) @@ -81,22 +85,15 @@ export async function drivePopup(popup: Page, driver: Driver) { ) } -/** - * 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 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"]') - .click({ timeout: 5_000 }) - .catch(() => {}) - return true +/** A click on the first visible match of a locator, at most once every 5 s; whether it found one. */ +export function throttledClick() { + let clickedAt = 0 + return async (target: Locator) => { + const visible = target.filter({ visible: true }).first() + if (Date.now() - clickedAt < 5_000 || !(await visible.count())) return false + clickedAt = Date.now() + // The page may already be leaving; the next poll sees where it went. + await visible.click({ timeout: 5_000 }).catch(() => {}) + return true + } } diff --git a/ts/apps/dev/e2e/x.ts b/ts/apps/dev/e2e/x.ts index 6885a759..d63b69c8 100644 --- a/ts/apps/dev/e2e/x.ts +++ b/ts/apps/dev/e2e/x.ts @@ -1,10 +1,6 @@ -import type { BrowserContext, Page } from '@playwright/test' -import { clickFound, drivePopup } from './popup.ts' -import { onX, 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[]) => - restoreSession(context, cookies, (domain) => [domain, domain.replace('x.com', 'twitter.com')]) +import type { Page } from '@playwright/test' +import { drivePopup, throttledClick } from './popup.ts' +import { onX } from './session.ts' const CHALLENGE = [ 'performing security verification', @@ -13,19 +9,6 @@ const CHALLENGE = [ '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; 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)` - /** * Approve the OAuth app on X in `popup`, signed in by the saved session, until * X returns it to Bridge's callback. A port of libid-server-rs' ceremony-tests/src/browser/x.rs @@ -33,7 +16,8 @@ const FIND_CONSENT = `[...document.querySelectorAll("[data-testid='OAuth_Consent * expired. */ export async function authorizeOnX(popup: Page) { - let consentedAt = 0 + const click = throttledClick() + let cookiesAccepted = false let challengedAt: number | undefined await drivePopup(popup, { name: 'X', @@ -45,13 +29,18 @@ 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` (libid-server-rs, branch feat/live-ceremony-tests).', + 'X asked to sign in: the saved session has expired. Renew it with `ceremony export x` (libid-server-rs).', ) } else if (url.pathname.startsWith('/i/oauth2/authorize')) { challengedAt = undefined - await popup.evaluate(ACCEPT_COOKIES).catch(() => {}) - if (Date.now() - consentedAt > 10_000 && (await clickFound(popup, FIND_CONSENT))) - consentedAt = Date.now() + if (!cookiesAccepted) + cookiesAccepted = await click(popup.getByRole('button', { name: 'Accept all cookies' })) + // X serves more than one consent page. + await click( + popup + .getByTestId('OAuth_Consent_Button') + .or(popup.getByRole('button', { name: /^authorize app$/i })), + ) } }, }) From e925a0d0d7062b01a13869927ea13a5f5ddf26cf Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 09:49:01 +0300 Subject: [PATCH 099/103] refactor(dev)!: one platform switch; trust the ceremony's validation - LIBID_LIVE_PLATFORMS set means every listed platform must have its variables (one error names those missing); unset, every platform with its variables runs and the rest are skipped. LIBID_REQUIRE_LIVE is gone. Alternate variable names are lists, not an `A|B` string. - Saved sessions restore with a plain map: no twitter.com copy of X's cookies. A partition key in the Rust export's CDP object form maps to its top-level site. - The spec checks the run is accepted for the expected platform and account; the ceremony already validates the proof, the nodes, the ids and the expiry. The row's message is read only for a run that was not accepted. BREAKING CHANGE: LIBID_REQUIRE_LIVE is removed; set LIBID_LIVE_PLATFORMS to require platforms. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/e2e/ceremony.live.spec.ts | 89 +++++++++++---------------- ts/apps/dev/e2e/session.test.ts | 45 +++++++------- ts/apps/dev/e2e/session.ts | 54 ++++++++-------- ts/apps/dev/e2e/settings.ts | 47 +++++++------- 4 files changed, 105 insertions(+), 130 deletions(-) diff --git a/ts/apps/dev/e2e/ceremony.live.spec.ts b/ts/apps/dev/e2e/ceremony.live.spec.ts index fcaea5a6..aa4effbb 100644 --- a/ts/apps/dev/e2e/ceremony.live.spec.ts +++ b/ts/apps/dev/e2e/ceremony.live.spec.ts @@ -6,24 +6,19 @@ import { authorizeOnGoogle } from './google.ts' import { presentAsPerson } from './person.ts' import { watchAppFailure, watchReturn } from './popup.ts' import { googleSession, restoreSession, xSession } from './session.ts' -import { env, livePlatforms, missing } from './settings.ts' -import { authorizeOnX, restoreXSession } from './x.ts' - -const PLATFORMS = { - GitHub: { id: 'github', proof: 'bearerLinkProof' }, - X: { id: 'x', proof: 'bearerLinkProof' }, - Google: { id: 'google', proof: 'identityProof' }, -} as const +import { env, missing, runnablePlatforms } from './settings.ts' +import { authorizeOnX } from './x.ts' /** * Start `name`'s ceremony from the app, let `authorize` drive the popup, then - * check the run's result: accepted, the proved account and its nodes, and a - * non-empty, unexpired proof. + * check the run's result: accepted, for `user` on `platform`. The ceremony + * itself validates the proof, the nodes and the ids. */ async function proveThroughTheUi( page: Page, context: BrowserContext, - name: keyof typeof PLATFORMS, + name: string, + platform: PlatformId, user: string, authorize: (popup: Page) => Promise, ) { @@ -34,50 +29,38 @@ async function proveThroughTheUi( const popup = await launch(page, name) const row = newestRun(page) const id = (await row.getAttribute('data-ceremony-id'))! - const status = () => page.evaluate((id) => window.results.get(id)?.status, id) + const result = () => + page.evaluate((id) => { + const result = window.results.get(id) + const identity = result && 'identity' in result ? result.identity : undefined + return ( + result && { + status: result.status, + platform: identity?.platformId, + user: identity?.userName, + } + ) + }, id) + const message = async () => (await row.locator('.run-status').textContent()) ?? '' watchAppFailure(popup, async () => { - const now = await status() - return now && now !== 'accepted' - ? (await row.locator('.run-status').textContent()) || now - : undefined + const status = (await result())?.status + return status && status !== 'accepted' ? await message() : undefined }) await authorize(popup) - await expect.poll(status, { timeout: 8 * 60_000 }).toBeDefined() - expect(await status(), (await row.locator('.run-status').textContent()) ?? '').toBe('accepted') - const result = await page.evaluate((id) => { - const result = window.results.get(id) - if (result?.status !== 'accepted') return undefined - const { identity, oauthProof } = result - const proof = oauthProof.proof as unknown as Record - return { - platformId: identity.platformId, - userId: identity.userId, - userName: identity.userName.toLowerCase(), - nodes: [identity.idNode.length, identity.handleNode.length], - proofBytes: Object.fromEntries( - Object.entries(proof).map(([field, value]) => [field, (value as Uint8Array)?.length]), - ), - version: oauthProof.platformCeremonyVersion, - expiresAt: oauthProof.expiresAt, - } - }, id) - const { id: platform, proof } = PLATFORMS[name] - expect(result?.platformId).toBe(platform) - // The account id as sent: GitHub's and X's are decimal; Google's `sub`, any string. - expect(result?.userId).toMatch(name === 'Google' ? /^(?!0x)./ : /^\d+$/) - expect(result?.userName).toBe(user.toLowerCase()) - expect(result?.nodes).toEqual([32, 32]) - expect(result?.proofBytes[proof]).toBeGreaterThan(0) - expect(result?.version).toBeGreaterThan(0) - expect(result?.expiresAt).toBeGreaterThan(Date.now() / 1000) + await expect.poll(result, { timeout: 8 * 60_000 }).toBeDefined() + const outcome = (await result())! + if (outcome.status !== 'accepted') expect(outcome.status, await message()).toBe('accepted') + expect(outcome.platform).toBe(platform) + expect(outcome.user?.toLowerCase()).toBe(user.toLowerCase()) } -/** Skip `platform`'s test unless it is selected and its settings are all set. */ -function skipUnlessLive(platform: PlatformId) { - test.skip(!livePlatforms().includes(platform), `${platform} is not in LIBID_LIVE_PLATFORMS`) - test.skip(missing(platform).length > 0, `${missing(platform).join(', ')} not set`) -} +/** Skip `platform`'s test unless it runs: listed, or with its variables set. */ +const skipUnlessLive = (platform: PlatformId) => + test.skip( + !runnablePlatforms().includes(platform), + `not listed in LIBID_LIVE_PLATFORMS, or ${missing(platform).join(', ')} not set`, + ) test('a GitHub identity is proved in the browser', async ({ page, context }) => { skipUnlessLive('github') @@ -86,7 +69,7 @@ test('a GitHub identity is proved in the browser', async ({ page, context }) => password: env('GH_TEST_ALICE_PASSWORD')!, totpSecret: env('GH_TEST_ALICE_TOTP_SECRET')!, } - await proveThroughTheUi(page, context, 'GitHub', account.username, (popup) => + await proveThroughTheUi(page, context, 'GitHub', 'github', account.username, (popup) => authorizeOnGitHub(popup, account), ) }) @@ -94,8 +77,8 @@ test('a GitHub identity is proved in the browser', async ({ page, context }) => test('an X identity is proved in the browser', async ({ page, context }) => { skipUnlessLive('x') await presentAsPerson(context) - await restoreXSession(context, xSession()!) - await proveThroughTheUi(page, context, 'X', env('X_TEST_ALICE_USERNAME')!, authorizeOnX) + await restoreSession(context, xSession()!) + await proveThroughTheUi(page, context, 'X', 'x', env('X_TEST_ALICE_USERNAME')!, authorizeOnX) }) test('a Google identity is proved in the browser', async ({ page, context }) => { @@ -103,7 +86,7 @@ test('a Google identity is proved in the browser', async ({ page, context }) => const email = env('GOOGLE_TEST_ALICE_EMAIL')! await presentAsPerson(context) await restoreSession(context, googleSession()!) - await proveThroughTheUi(page, context, 'Google', email, (popup) => + await proveThroughTheUi(page, context, 'Google', 'google', email, (popup) => authorizeOnGoogle(popup, email), ) }) diff --git a/ts/apps/dev/e2e/session.test.ts b/ts/apps/dev/e2e/session.test.ts index 89ee324c..ad6afe5d 100644 --- a/ts/apps/dev/e2e/session.test.ts +++ b/ts/apps/dev/e2e/session.test.ts @@ -1,34 +1,35 @@ // The mapping restoreSession applies, on a synthetic export. import assert from 'node:assert/strict' import { test } from 'node:test' -import { cookieParams } from './session.ts' +import { cookieParam } from './session.ts' test('cookies map to addCookies as the export spells them', () => { - const exported = [ - { name: 'a', value: '1', domain: '.x.com', sameSite: 'no_restriction', expires: 9e9 }, - { - name: 'b', - value: '2', - domain: 'x.com', - path: '/i', - same_site: '', - partitionKey: 'https://x.com', - }, - ] - const [domain, copy, hostOnly] = cookieParams(exported, (d) => - d[0] === '.' ? [d, '.twitter.com'] : [d], - ) + const domain = cookieParam({ + name: 'a', + value: '1', + domain: '.x.com', + sameSite: 'no_restriction', + expires: 9e9, + }) assert.deepEqual( - [domain?.domain, domain?.path, domain?.sameSite, domain?.expires, copy?.domain], - ['.x.com', '/', 'None', 9e9, '.twitter.com'], + [domain.domain, domain.path, domain.sameSite, domain.expires], + ['.x.com', '/', 'None', 9e9], ) + const hostOnly = cookieParam({ + name: 'b', + value: '2', + domain: 'x.com', + path: '/i', + same_site: '', + partition_key: { topLevelSite: 'https://x.com' }, + }) assert.deepEqual( [ - hostOnly?.domain, - hostOnly?.path, - 'sameSite' in hostOnly!, - hostOnly?.expires, - hostOnly?.partitionKey, + hostOnly.domain, + hostOnly.path, + 'sameSite' in hostOnly, + hostOnly.expires, + hostOnly.partitionKey, ], ['x.com', '/i', false, -1, 'https://x.com'], ) diff --git a/ts/apps/dev/e2e/session.ts b/ts/apps/dev/e2e/session.ts index 68c4858d..6101c8d5 100644 --- a/ts/apps/dev/e2e/session.ts +++ b/ts/apps/dev/e2e/session.ts @@ -4,7 +4,7 @@ import type { BrowserContext } from '@playwright/test' import { env, settingPath } from './settings.ts' /** One cookie as libid-server-rs' `ceremony export` or Playwright saves it; both key casings occur. */ -export interface Stored { +interface Stored { name: string value: string domain: string @@ -16,13 +16,13 @@ export interface Stored { sameSite?: string | null expires?: number expirationDate?: number - partition_key?: unknown - partitionKey?: unknown + partition_key?: string | { topLevelSite?: string } | null + partitionKey?: string | { topLevelSite?: string } | null } const hostOf = (cookie: Stored) => cookie.domain.replace(/^\./, '') export const onX = (host: string) => host === 'x.com' || host.endsWith('.x.com') -export const onGoogle = (host: string) => host === 'google.com' || host.endsWith('.google.com') +const onGoogle = (host: string) => host === 'google.com' || host.endsWith('.google.com') /** * The platform's cookies in `_COOKIES` (base64 JSON) or `_COOKIES_FILE`: @@ -77,31 +77,25 @@ const SAME_SITE: Record = { type CookieParam = Parameters[0][number] -/** The cookies to set, on their own domain or each of `domainsOf`'s; a dotless one stays host-only. */ -export function cookieParams( - cookies: Stored[], - domainsOf: (domain: string) => string[] = (domain) => [domain], -): CookieParam[] { - return cookies.flatMap((cookie) => { - const sameSite = SAME_SITE[(cookie.same_site ?? cookie.sameSite ?? '').toLowerCase()] - const expires = cookie.expires ?? cookie.expirationDate ?? 0 - const partitionKey = cookie.partition_key ?? cookie.partitionKey - return 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, - expires: expires > 0 ? expires : -1, - ...(sameSite && { sameSite }), - ...(typeof partitionKey === 'string' && { partitionKey }), - })) - }) +/** A cookie as addCookies takes it; a dotless domain stays host-only. */ +export function cookieParam(cookie: Stored): CookieParam { + const sameSite = SAME_SITE[(cookie.same_site ?? cookie.sameSite ?? '').toLowerCase()] + const expires = cookie.expires ?? cookie.expirationDate ?? 0 + // The Rust export writes CDP's object; Playwright takes its top-level site. + const key = cookie.partition_key ?? cookie.partitionKey + const partitionKey = typeof key === 'string' ? key : key?.topLevelSite + return { + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path || '/', + secure: cookie.secure ?? true, + httpOnly: cookie.http_only ?? cookie.httpOnly ?? false, + expires: expires > 0 ? expires : -1, + ...(sameSite && { sameSite }), + ...(partitionKey && { partitionKey }), + } } -export const restoreSession = ( - context: BrowserContext, - cookies: Stored[], - domainsOf?: (domain: string) => string[], -) => context.addCookies(cookieParams(cookies, domainsOf)) +export const restoreSession = (context: BrowserContext, cookies: Stored[]) => + context.addCookies(cookies.map(cookieParam)) diff --git a/ts/apps/dev/e2e/settings.ts b/ts/apps/dev/e2e/settings.ts index 376c6c97..2ea11589 100644 --- a/ts/apps/dev/e2e/settings.ts +++ b/ts/apps/dev/e2e/settings.ts @@ -5,11 +5,14 @@ import { dirname, resolve } from 'node:path' import { parseEnv } from 'node:util' import type { PlatformId } from '@libid/ceremony' -/** Each platform's variables; `A|B` is either. The type keeps every platform listed. */ -export const PLATFORM_VARIABLES: Record = { - github: ['GH_TEST_ALICE_USERNAME', 'GH_TEST_ALICE_PASSWORD', 'GH_TEST_ALICE_TOTP_SECRET'], - x: ['X_TEST_ALICE_USERNAME', 'X_TEST_ALICE_COOKIES|X_TEST_ALICE_COOKIES_FILE'], - google: ['GOOGLE_TEST_ALICE_EMAIL', 'GOOGLE_TEST_ALICE_COOKIES|GOOGLE_TEST_ALICE_COOKIES_FILE'], +/** Each platform's variables, each set by any one name of its list. */ +const PLATFORM_VARIABLES: Record = { + github: [['GH_TEST_ALICE_USERNAME'], ['GH_TEST_ALICE_PASSWORD'], ['GH_TEST_ALICE_TOTP_SECRET']], + x: [['X_TEST_ALICE_USERNAME'], ['X_TEST_ALICE_COOKIES', 'X_TEST_ALICE_COOKIES_FILE']], + google: [ + ['GOOGLE_TEST_ALICE_EMAIL'], + ['GOOGLE_TEST_ALICE_COOKIES', 'GOOGLE_TEST_ALICE_COOKIES_FILE'], + ], } const PLATFORMS = Object.keys(PLATFORM_VARIABLES) as PlatformId[] @@ -33,39 +36,33 @@ export const settingPath = (path: string) => { return resolve(file ? dirname(file) : invoked(), path) } -export const requireLive = () => /^(1|true)$/i.test(env('LIBID_REQUIRE_LIVE') ?? '') - -/** LIBID_LIVE_PLATFORMS, comma-separated, or every platform when it is unset. */ -export function livePlatforms(): PlatformId[] { - const listed = process.env.LIBID_LIVE_PLATFORMS - if (listed === undefined) return PLATFORMS - const names = listed - .split(',') +/** The platforms LIBID_LIVE_PLATFORMS lists, comma-separated; undefined when it is unset. */ +function listed(): PlatformId[] | undefined { + const names = env('LIBID_LIVE_PLATFORMS') + ?.split(',') .map((name) => name.trim()) - .filter(Boolean) - const unknown = names.filter((name) => !PLATFORMS.includes(name as PlatformId)) + const unknown = names?.filter((name) => !PLATFORMS.includes(name as PlatformId)) ?? [] if (unknown.length) throw new Error(`LIBID_LIVE_PLATFORMS names ${unknown.join(', ')}; use ${PLATFORMS.join(', ')}`) - if (!names.length && requireLive()) - throw new Error('LIBID_REQUIRE_LIVE is set but LIBID_LIVE_PLATFORMS selects no platform') - return names as PlatformId[] + return names as PlatformId[] | undefined } /** The variables `platform` lacks. */ export const missing = (platform: PlatformId) => PLATFORM_VARIABLES[platform] - .filter((names) => !names.split('|').some(env)) - .map((names) => names.replace('|', ' or ')) + .filter((names) => !names.some(env)) + .map((names) => names.join(' or ')) -/** The selected platforms whose tests run: those with every variable set. */ -export const runnablePlatforms = () => livePlatforms().filter((p) => !missing(p).length) +/** The platforms whose tests run: the listed ones, else every one with its variables set. */ +export const runnablePlatforms = () => + listed() ?? PLATFORMS.filter((platform) => !missing(platform).length) -/** Under LIBID_REQUIRE_LIVE, fail once, naming every variable a selected platform lacks. */ +/** A listed platform must have its variables: fail once, naming every one absent. */ export function checkSettings() { - const absent = livePlatforms().flatMap((p) => + const absent = (listed() ?? []).flatMap((p) => missing(p).length ? [`${p}: ${missing(p).join(', ')}`] : [], ) - if (requireLive() && absent.length) + if (absent.length) throw new Error( `Missing live test settings (${absent.join('; ')}). Put them in the file LIBID_TEST_ENV_FILE names, or export them.`, ) From d69656509805db02686950384ee07a8657eadaec Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 09:49:01 +0300 Subject: [PATCH 100/103] refactor(dev): the live config owns the stack log; local.ts constants - With LIBID_STACK_LOGS the live webServer command redirects all of its output there (`exec >>"$LIBID_STACK_LOGS" 2>&1`), the build's included; services.ts writes to its inherited output again, and the one path rule stays settings.ts'. - services.ts no longer probes the app's port: Vite's strictPort and Playwright already refuse a taken one. - local.ts gains APP_PORT and BRIDGE_CONFIG, used by vite.config.ts, services.ts' readiness check and app.fixtures.ts; app.spec.ts and app.ts use NOTARY and BRIDGE directly. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/local.ts | 7 ++++-- ts/apps/dev/playwright.live.config.ts | 4 ++-- ts/apps/dev/services.ts | 33 +++++++++------------------ ts/apps/dev/src/app.fixtures.ts | 4 ++-- ts/apps/dev/src/app.spec.ts | 3 ++- ts/apps/dev/src/app.ts | 5 ++-- ts/apps/dev/vite.config.ts | 4 ++-- 7 files changed, 26 insertions(+), 34 deletions(-) diff --git a/ts/apps/dev/local.ts b/ts/apps/dev/local.ts index 7096fc8b..1f4c186d 100644 --- a/ts/apps/dev/local.ts +++ b/ts/apps/dev/local.ts @@ -1,7 +1,10 @@ // The origins the app, services.ts and the tests share. compose.yaml and -// bridge-config.toml repeat them; services.ts and local.test.ts check both. +// bridge-config.toml repeat the ports they need. +export const APP_PORT = 4691 /** The app, served by Vite; bridge-config.toml admits this origin. */ -export const APP_ORIGIN = 'http://localhost:4691' +export const APP_ORIGIN = `http://localhost:${APP_PORT}` export const BRIDGE = 'http://localhost:4682' +/** Bridge's configuration document, which answers once Bridge is ready. */ +export const BRIDGE_CONFIG = `${BRIDGE}/api/v1/ceremony/config` export const NOTARY = 'http://localhost:4687' diff --git a/ts/apps/dev/playwright.live.config.ts b/ts/apps/dev/playwright.live.config.ts index d082e0d3..76b5bc1d 100644 --- a/ts/apps/dev/playwright.live.config.ts +++ b/ts/apps/dev/playwright.live.config.ts @@ -33,8 +33,8 @@ export default defineConfig({ webServer: runnablePlatforms().length ? { // exec: Playwright's SIGTERM reaches services.ts, which brings the stack - // down. With LIBID_STACK_LOGS, the build's output goes there too. - command: `pnpm build:deps${stackLogs ? ' >>"$LIBID_STACK_LOGS" 2>&1' : ''} && exec node services.ts --app`, + // down. LIBID_STACK_LOGS takes all of its output, the build's included. + command: `${stackLogs ? 'exec >>"$LIBID_STACK_LOGS" 2>&1; ' : ''}pnpm build:deps && exec node services.ts --app`, env: { ...stackEnv, ...(stackLogs && { LIBID_STACK_LOGS: stackLogs }) }, url: APP_ORIGIN, reuseExistingServer: false, diff --git a/ts/apps/dev/services.ts b/ts/apps/dev/services.ts index 2e60933e..576ca09e 100644 --- a/ts/apps/dev/services.ts +++ b/ts/apps/dev/services.ts @@ -1,24 +1,17 @@ // Start the real Bridge, notary and emitted CCDP; no OAuth mocks. -// LIBID_STACK_LOGS names a file every child's output, containers included, -// is appended to; a relative path is relative to where pnpm was invoked. import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' -import { openSync } from 'node:fs' import { connect } from 'node:net' -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import { createServer as createViteServer, type ViteDevServer } from 'vite' -import { APP_ORIGIN, BRIDGE } from './local.ts' +import { APP_ORIGIN, BRIDGE_CONFIG } from './local.ts' const root = fileURLToPath(new URL('.', import.meta.url)) // Distinct Compose ownership for each checkout. No shared container names. const project = `libid-dev-${createHash('sha256').update(root).digest('hex').slice(0, 12)}` const composeArgs = ['compose', '-p', project, '-f', join(root, 'compose.yaml')] -const logPath = process.env.LIBID_STACK_LOGS -const logs = logPath ? openSync(resolve(process.env.INIT_CWD ?? root, logPath), 'a') : undefined -const stdio = - logs === undefined ? 'inherit' : (['ignore', logs, logs] as ['ignore', number, number]) /** Whether something on this host accepts connections on `port`. */ const listening = (port: number) => new Promise((resolve) => { @@ -45,7 +38,6 @@ if (!process.argv.includes('--ccdp')) { console.error('Could not run Docker Compose. Install Docker with Compose and start its engine.') process.exit(1) } - if (process.argv.includes('--app')) ports.push(Number(new URL(APP_ORIGIN).port)) const open = await Promise.all(ports.map(listening)) const taken = ports.filter((_, index) => open[index]) if (taken.length) { @@ -61,7 +53,7 @@ execFileSync( ['--filter', '@libid/ceremony', 'build:ccdp-artifacts', '--out-dir', join(root, '.cache/ccdp')], { cwd: root, - stdio, + stdio: 'inherit', }, ) // Refresh the bind mounts after the build atomically replaces its output directory. @@ -70,7 +62,7 @@ if (process.argv.includes('--ccdp')) { 'docker', [...composeArgs, 'up', '--wait', '--no-deps', '--no-build', '--force-recreate', 'ccdp'], { - stdio, + stdio: 'inherit', }, ) process.exit(0) @@ -91,7 +83,7 @@ function stop(code: number) { } } // Keep this child alive through completion, including after a partial startup. - const down = spawn('docker', [...composeArgs, 'down'], { stdio }) + const down = spawn('docker', [...composeArgs, 'down'], { stdio: 'inherit' }) down.on('error', () => console.error('Could not stop development containers. Check Docker.')) down.on('close', (code) => { if (code !== 0) process.exitCode = 1 @@ -100,7 +92,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, + stdio: 'inherit', detached: true, }) compose.on('error', () => { @@ -116,14 +108,11 @@ if (process.argv.includes('--app')) { while (!stopping) { try { // Docker publishes on 127.0.0.1 only. - const response = await fetch( - new URL('/api/v1/ceremony/config', BRIDGE.replace('localhost', '127.0.0.1')), - { - headers: { Origin: APP_ORIGIN }, - redirect: 'error', - signal: AbortSignal.timeout(1000), - }, - ) + const response = await fetch(BRIDGE_CONFIG.replace('localhost', '127.0.0.1'), { + headers: { Origin: APP_ORIGIN }, + redirect: 'error', + signal: AbortSignal.timeout(1000), + }) await response.body?.cancel() if (response.status === 200) break } catch { diff --git a/ts/apps/dev/src/app.fixtures.ts b/ts/apps/dev/src/app.fixtures.ts index f4719d97..af8e479f 100644 --- a/ts/apps/dev/src/app.fixtures.ts +++ b/ts/apps/dev/src/app.fixtures.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { type BrowserContext, expect, type Locator, type Page } from '@playwright/test' import { buildGooglePublicInputs } from '../../../packages/ceremony/src/platforms/google/1/validation.js' -import { BRIDGE } from '../local.ts' +import { BRIDGE_CONFIG } from '../local.ts' // Helpers for app.spec.ts: the Bridge and app, the run history, and the popup-side fixtures. @@ -23,7 +23,7 @@ declare global { } } -export const configUrl = new URL('/api/v1/ceremony/config', BRIDGE).href +export const configUrl = BRIDGE_CONFIG // The Bridge selects CCDP, so tests use their own origin, apart from the development one. export const ccdp = 'http://localhost:4684' export const config = { diff --git a/ts/apps/dev/src/app.spec.ts b/ts/apps/dev/src/app.spec.ts index 5bb4d6cf..c6530d02 100644 --- a/ts/apps/dev/src/app.spec.ts +++ b/ts/apps/dev/src/app.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test' +import { NOTARY } from '../local.ts' import { ccdp, closeButton, @@ -52,7 +53,7 @@ test('unavailable Bridge disables launch; reload loads compatible platforms', as available = true await page.reload() await expect(page.getByRole('status')).toContainText('Ready.') - await expect(page.locator('dl')).toContainText('http://localhost:4687') + await expect(page.locator('dl')).toContainText(NOTARY) await expect(page.locator('dl')).not.toContainText('Ledger') await expect(launchButtons(page)).toHaveText(['Google']) await expect(launchButton(page, 'Google')).toBeEnabled() diff --git a/ts/apps/dev/src/app.ts b/ts/apps/dev/src/app.ts index 25771733..8a4d48b0 100644 --- a/ts/apps/dev/src/app.ts +++ b/ts/apps/dev/src/app.ts @@ -17,7 +17,6 @@ declare global { results: Map } } -const oauthBridge = BRIDGE /** A synthetic ledger routed to the local notary. */ const ledger: LedgerId = { notaryAddress: () => NOTARY, @@ -25,7 +24,7 @@ const ledger: LedgerId = { const platforms = document.querySelector('#platforms')! const status = document.querySelector('#status')! window.results = new Map() -document.querySelector('#bridge')!.textContent = oauthBridge +document.querySelector('#bridge')!.textContent = BRIDGE document.querySelector('#notary')!.textContent = ledger.notaryAddress() const names: Record = { google: 'Google', x: 'X', github: 'GitHub' } /** A new `tag` element with `properties` assigned. */ @@ -36,7 +35,7 @@ const element = ( let client: CCDPClient | undefined async function initialize() { try { - client = await createCCDPClient({ oauthBridge }) + client = await createCCDPClient({ oauthBridge: BRIDGE }) platforms.replaceChildren( ...client.enabledPlatforms.map((platform) => { const launch = document.createElement('a') diff --git a/ts/apps/dev/vite.config.ts b/ts/apps/dev/vite.config.ts index d42bc267..d1bc06e1 100644 --- a/ts/apps/dev/vite.config.ts +++ b/ts/apps/dev/vite.config.ts @@ -1,7 +1,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' -import { APP_ORIGIN } from './local.ts' +import { APP_PORT } from './local.ts' const root = fileURLToPath(new URL('.', import.meta.url)) const directory = join(root, '.cache/dev') @@ -10,7 +10,7 @@ export default defineConfig({ cacheDir: join(directory, 'vite'), server: { host: 'localhost', - port: Number(new URL(APP_ORIGIN).port), + port: APP_PORT, strictPort: true, }, build: { outDir: join(directory, 'app'), emptyOutDir: true }, From d690d0a5adcfb9f4aa86f0007fb0beaeae8fb025 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 09:49:01 +0300 Subject: [PATCH 101/103] ci(live-e2e): show the stack log on failure; one platform switch The failure step only prints the end of the stack log: the test step stops its own stack. LIBID_REQUIRE_LIVE is gone (each job lists its platforms), both jobs exclude forks and Dependabot the same way, and each job's timeout carries its arithmetic in one comment. The README follows, and .gitignore drops a pattern *.env.test already covers. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 27 +++++++++------------------ .gitignore | 1 - ts/apps/dev/README.md | 8 ++++---- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 623832c0..483336fb 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -29,10 +29,8 @@ permissions: contents: read env: - # Where services.ts writes the stack's output; a failed job prints its end. + # Where the stack's output goes; a failed job prints its end. LIBID_STACK_LOGS: /tmp/libid-live-stack.log - # A missing secret fails the run instead of skipping the test. - LIBID_REQUIRE_LIVE: "1" jobs: ceremony: @@ -47,8 +45,7 @@ jobs: concurrency: group: live-e2e-github-x cancel-in-progress: false - # Test step 45 (stack 10 + two tests of 15 + stop 2 = 42) + setup 15. - timeout-minutes: 60 + timeout-minutes: 60 # test step 45: stack 10 + 2 tests × 15 + stop 2 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -72,11 +69,9 @@ jobs: run: pnpm -C ts/apps/dev test:live # The stack gets no secret (playwright.live.config.ts); GitHub masks them anyway. - - name: Stop the stack and show its log + - name: Show the stack log if: ${{ !cancelled() && steps.test.outcome != 'success' }} - run: | - docker compose ls -q | grep '^libid-dev-' | xargs -r -I{} docker compose -p {} down || true - tail -n 300 "$LIBID_STACK_LOGS" || true + run: tail -n 300 "$LIBID_STACK_LOGS" || true - name: Keep the screenshots if: ${{ !cancelled() && steps.test.outcome != 'success' }} @@ -94,16 +89,14 @@ jobs: # Runs while LIBID_GOOGLE_RUNNER is `ssm`. if: >- vars.LIBID_GOOGLE_RUNNER == 'ssm' && - (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || - (github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name == github.repository && + (github.event_name != 'pull_request' || + (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]')) runs-on: ubuntu-latest concurrency: group: live-e2e-google cancel-in-progress: false - # Test step 30 (stack 10 + one test of 15 + stop 2 = 27) + setup and proxy 15. - timeout-minutes: 45 + timeout-minutes: 45 # test step 30: stack 10 + 1 test × 15 + stop 2 permissions: contents: read id-token: write @@ -146,11 +139,9 @@ jobs: GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} run: pnpm -C ts/apps/dev test:live - - name: Stop the stack and show its log + - name: Show the stack log if: ${{ !cancelled() && steps.test.outcome != 'success' }} - run: | - docker compose ls -q | grep '^libid-dev-' | xargs -r -I{} docker compose -p {} down || true - tail -n 300 "$LIBID_STACK_LOGS" || true + run: tail -n 300 "$LIBID_STACK_LOGS" || true - name: Keep the screenshots if: ${{ !cancelled() && steps.test.outcome != 'success' }} diff --git a/.gitignore b/.gitignore index 763af9aa..44918470 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ ts/apps/*/dist/ .env .env.local .env.*.local -.env.test *.env.test # @libid/popup e2e bundles and Playwright output diff --git a/ts/apps/dev/README.md b/ts/apps/dev/README.md index 7e6ca3c0..0c372912 100644 --- a/ts/apps/dev/README.md +++ b/ts/apps/dev/README.md @@ -118,7 +118,8 @@ LIBID_TEST_ENV_FILE=path/to/.env.test pnpm --filter @libid/dev test:live The variables carry the names libid-server-rs' ceremony-tests uses, so one `.env.test` serves both suites; keep it outside the repository, or name it -`.env.test`, which git ignores. A test whose variables are missing is skipped. +`.env.test`, which git ignores. Unless `LIBID_LIVE_PLATFORMS` lists it, a +platform whose variables are missing is skipped. | Variable | Platform | What | |---|---|---| @@ -129,10 +130,9 @@ The variables carry the names libid-server-rs' ceremony-tests uses, so one | `GOOGLE_TEST_ALICE_EMAIL` | Google | The test account. | | `GOOGLE_TEST_ALICE_COOKIES` or `GOOGLE_TEST_ALICE_COOKIES_FILE` | Google | A saved session, as for X (`ceremony export google`). | | `LIBID_TEST_ENV_FILE` | — | A dotenv file with the variables above; exported values win. | -| `LIBID_LIVE_PLATFORMS` | — | Comma-separated platforms to prove, such as `github,x`; all three when unset. | -| `LIBID_REQUIRE_LIVE` | — | `1` or `true`: a missing variable fails the run instead of skipping. | +| `LIBID_LIVE_PLATFORMS` | — | Comma-separated platforms to prove, such as `github,x`; each one listed must have its variables. Unset, every platform with its variables runs. | | `LIBID_TEST_PROXY` | — | A proxy for the browser's traffic, loopback aside. | -| `LIBID_STACK_LOGS` | — | A file the build's and the services' output is appended to. | +| `LIBID_STACK_LOGS` | — | A file all of the stack's output, the build's included, is appended to. | | `HEADED` | — | Set: show the browser. | In CI, [live-e2e.yml](../../../.github/workflows/live-e2e.yml) runs GitHub and X From 61cd9e0167488a9ac2ae379e9f98fe3af108ccd1 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 14:22:45 +0300 Subject: [PATCH 102/103] ci(live-e2e): the Google job runs on main only Pull requests no longer run it; the proxy role trusts this workflow on main only. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .github/workflows/live-e2e.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 483336fb..2d1fb189 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -86,12 +86,10 @@ jobs: name: Google ceremony through an SSM-reached proxy (live) # Google challenges a session from a runner's address, so the browser goes # through an EC2 proxy LIBID_GOOGLE_PROXY_ROLE reaches only by SSM. - # Runs while LIBID_GOOGLE_RUNNER is `ssm`. + # Runs while LIBID_GOOGLE_RUNNER is `ssm`, on main only: the role trusts + # this workflow on main and nothing else. if: >- - vars.LIBID_GOOGLE_RUNNER == 'ssm' && - (github.event_name != 'pull_request' || - (github.event.pull_request.head.repo.full_name == github.repository && - github.actor != 'dependabot[bot]')) + vars.LIBID_GOOGLE_RUNNER == 'ssm' && github.event_name != 'pull_request' runs-on: ubuntu-latest concurrency: group: live-e2e-google From 08373db96a6d43d7aab0dc777191a567253b20d9 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Fri, 23 Oct 2026 15:23:29 +0300 Subject: [PATCH 103/103] docs(dev): Google's session is signed in by hand through the proxy Google trusts a saved session only from the address it was signed in from, so it is made by hand in plain Chrome through CI's proxy and exported with e2e/export-session.ts; the expired-session error points there. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/apps/dev/README.md | 20 +++++++++++++++++++- ts/apps/dev/e2e/export-session.ts | 24 ++++++++++++++++++++++++ ts/apps/dev/e2e/google.ts | 2 +- 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 ts/apps/dev/e2e/export-session.ts diff --git a/ts/apps/dev/README.md b/ts/apps/dev/README.md index 0c372912..21d37caa 100644 --- a/ts/apps/dev/README.md +++ b/ts/apps/dev/README.md @@ -128,7 +128,7 @@ platform whose variables are missing is skipped. | `X_TEST_ALICE_USERNAME` | X | The test account's handle. | | `X_TEST_ALICE_COOKIES` or `X_TEST_ALICE_COOKIES_FILE` | X | A saved session: base64 of the JSON cookie list from `ceremony export x`, or a path to that JSON, relative to the env file. | | `GOOGLE_TEST_ALICE_EMAIL` | Google | The test account. | -| `GOOGLE_TEST_ALICE_COOKIES` or `GOOGLE_TEST_ALICE_COOKIES_FILE` | Google | A saved session, as for X (`ceremony export google`). | +| `GOOGLE_TEST_ALICE_COOKIES` or `GOOGLE_TEST_ALICE_COOKIES_FILE` | Google | A saved session, made by hand: see [Google's session](#googles-session). | | `LIBID_TEST_ENV_FILE` | — | A dotenv file with the variables above; exported values win. | | `LIBID_LIVE_PLATFORMS` | — | Comma-separated platforms to prove, such as `github,x`; each one listed must have its variables. Unset, every platform with its variables runs. | | `LIBID_TEST_PROXY` | — | A proxy for the browser's traffic, loopback aside. | @@ -140,3 +140,21 @@ daily in one job, and Google in another through a proxy whose address Google accepts. Each job gets only its own platforms' secrets. On a failure it prints the end of the stack log to the job log, where GitHub masks the secrets, and uploads the screenshots. + +### Google's session + +Google challenges a saved session used from an address other than the one it +was signed in from, and refuses sign-in in an automated browser. So the session +is made once by hand, through the same proxy CI uses: + +1. Open the proxy locally: + `aws ssm start-session --target --document-name AWS-StartPortForwardingSession --parameters portNumber=3128,localPortNumber=3128`. +2. In plain Chrome with a fresh profile, sign in to the test account through it: + `google-chrome --user-data-dir=/tmp/google-session --password-store=basic --proxy-server=http://127.0.0.1:3128 https://myaccount.google.com/`. + Then close Chrome. +3. Export and store it: `node e2e/export-session.ts /tmp/google-session google.json`, + then `base64 -w0 google.json | gh secret set GOOGLE_TEST_ALICE_COOKIES`, and + delete `google.json` and the profile. + +Do this again when the Google job reports an expired session, or when the +proxy's address changes. diff --git a/ts/apps/dev/e2e/export-session.ts b/ts/apps/dev/e2e/export-session.ts new file mode 100644 index 00000000..8be5ac31 --- /dev/null +++ b/ts/apps/dev/e2e/export-session.ts @@ -0,0 +1,24 @@ +// Saves a Chrome profile's session as the JSON GOOGLE_TEST_ALICE_COOKIES holds +// (base64-encoded). Google refuses sign-in in an automated browser, so a person +// signs in first, in plain Chrome, through the proxy CI uses: +// +// google-chrome --user-data-dir=/tmp/google-session --password-store=basic \ +// --proxy-server=http://127.0.0.1:3128 https://myaccount.google.com/ +// +// Close that Chrome, then: node e2e/export-session.ts /tmp/google-session out.json +import { chromium } from '@playwright/test' + +const [profile, out] = process.argv.slice(2) +if (!profile || !out) throw new Error('Usage: node e2e/export-session.ts ') + +// The same Chrome and password store that wrote the profile, so its cookies decrypt. +const context = await chromium.launchPersistentContext(profile, { + channel: 'chrome', + headless: true, + args: ['--password-store=basic'], +}) +await context.storageState({ path: out }) +await context.close() +console.log( + `Saved ${out}. It is a credential: keep it out of the repository and delete it after use.`, +) diff --git a/ts/apps/dev/e2e/google.ts b/ts/apps/dev/e2e/google.ts index 92f41fd0..d03cd560 100644 --- a/ts/apps/dev/e2e/google.ts +++ b/ts/apps/dev/e2e/google.ts @@ -33,7 +33,7 @@ export async function authorizeOnGoogle(popup: Page, email: string) { const login = popup.locator('input[type=password],input[type=email],input[name=identifier]') if (await login.filter({ visible: true }).count()) throw new Error( - 'Google asked to sign in: the saved session has expired. Renew it with `ceremony export google` (libid-server-rs).', + "Google asked to sign in: the saved session has expired. Renew it by hand through the proxy (README, Google's session).", ) // The account's row, else a Continue or Allow button. await click(