Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fb3d0a7
feat(bind): an app that binds a GitHub identity on a local chain
SupremaLex Oct 1, 2026
0f213b8
test(bind): bind a real GitHub identity end to end in Playwright
SupremaLex Oct 1, 2026
71c4ddc
fix(bind): pass the workspace lint and format checks
SupremaLex Oct 1, 2026
717f9f0
ci(bind): run the live GitHub binding nightly and on demand
SupremaLex Oct 2, 2026
31f3b8f
test(bind): bind a real X identity end to end
SupremaLex Oct 2, 2026
5e75e12
test(bind): bind a real Google identity end to end
SupremaLex Oct 2, 2026
eb0abbb
ci(bind): run the live bindings on pull requests
SupremaLex Oct 2, 2026
15df634
fix(ci): set the stack log path on the step
SupremaLex Oct 2, 2026
7d360db
chore: merge main into feat/e2e-bind
SupremaLex Oct 5, 2026
8d04f8b
fix(bind): follow main's CCDP client API
SupremaLex Oct 5, 2026
4b2f2c9
build(bind): deploy with libid-deploy 0.15.0
SupremaLex Oct 5, 2026
d8bee4a
test(bind): fail fast on a failed ceremony and a Google challenge
SupremaLex Oct 2, 2026
f002fb4
ci(bind): leave the Google binding out of the live run
SupremaLex Oct 5, 2026
2a0cc70
fix(ci): never publish the test accounts' secrets from a failed live run
SupremaLex Oct 5, 2026
5886746
refactor(bind): one popup driver, one constants module, a page outcome
SupremaLex Oct 5, 2026
96d47c8
fix(bind): bind through the verifier slot, with the wallet the run st…
SupremaLex Oct 5, 2026
77f1c37
fix(bind): start the stack fresh, and never over another one
SupremaLex Oct 5, 2026
5b2b0a7
fix(bind): finish the GitHub authorization on GitHub's redirect
SupremaLex Oct 5, 2026
29510f4
ci(bind): nest the live run's budgets, and skip runs without secrets
SupremaLex Oct 5, 2026
69a2e72
fix(bind): the popup returns on Bridge's callback, inside one budget
SupremaLex Oct 5, 2026
62363f5
fix(bind): report "bound" only for a binding the registry and indexer…
SupremaLex Oct 5, 2026
ff1c242
fix(bind): one stack per checkout, its output kept off the job log
SupremaLex Oct 5, 2026
86166bf
ci(bind): per-PR concurrency, and scan the log the run finished with
SupremaLex Oct 5, 2026
ececb6d
refactor(bind): one stop signal for the stack, and overlap its startup
SupremaLex Oct 5, 2026
fce3132
refactor(bind): share saved sessions and click marking across drivers
SupremaLex Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions .github/workflows/live-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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.
#
# 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 * * *"

# 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-${{ 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

jobs:
bind:
name: GitHub and X bindings (live)
# 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
# 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

- name: Install Chromium
run: pnpm -C ts --filter @libid/bind 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, 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
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 }}
run: pnpm -C ts/apps/bind 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
id: scan
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: ""
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 }}
# 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' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: live-e2e
path: |
ts/apps/bind/test-results/**/*.png
ts/apps/bind/test-results/**/*.md
${{ runner.temp }}/stack-scanned.log
retention-days: 14
if-no-files-found: ignore
2 changes: 2 additions & 0 deletions ts/apps/bind/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.cache/
test-results/
15 changes: 15 additions & 0 deletions ts/apps/bind/bridge-config.toml
Original file line number Diff line number Diff line change
@@ -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"
133 changes: 133 additions & 0 deletions ts/apps/bind/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# 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. 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
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: ""
# 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"
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

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
92 changes: 92 additions & 0 deletions ts/apps/bind/e2e/bind.live.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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<void>,
) {
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!),
)
})
Loading
Loading