Skip to content

test(bind): live end-to-end binding for GitHub, X and Google, from UI to chain - #72

Draft
SupremaLex wants to merge 25 commits into
mainfrom
feat/e2e-bind
Draft

SupremaLex wants to merge 25 commits into
mainfrom
feat/e2e-bind

Conversation

@SupremaLex

@SupremaLex SupremaLex commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

A real end-to-end test of binding a platform identity, from the UI to a mined transaction and back, for GitHub, X and Google. It also adds the app it drives: the first code that takes the CCDP ceremony all the way to IdentityRegistry.bind.

What runs

ts/apps/bind:

  • stack.ts + compose.yaml start the whole stack locally:

    • Chain: anvil (a block every second, so block time follows the clock), with the real libID-contracts stack deployed by chain-configurations' libid-deploy v0.15.0, which carries the libid-circuits v0.6.0 verifiers main's ceremony proves for. The deploy tool is downloaded from its release and pinned by sha256.
    • Notary 0.4.0, signing with the key local-dev.toml trusts (anvil refactor(claim): port the OIDC circuit-input conversion from wasm to TypeScript #1).
    • Keeper 0.3.1, run once when the run binds Google: a notarized reading of Google's keys into GoogleJwtRoots.
    • Bridge 0.5.0 and the CCDP documents, as in apps/dev.
    • Indexer and API (usernames-indexer 0.4.0) with Postgres.
    • Bridge and indexer readiness are awaited while the deploy runs; quoteBind(github, 1) must equal two notary fees, and a verifier must sit at slot 1 for each platform the run binds.
    • One stack per checkout: a lock file refuses a second stack.ts, a leftover stack from an interrupted run is removed first, and a port already in use stops startup. Startup also fails fast when the addresses in local.ts, compose.yaml and keeper.toml drift from local-dev.toml.
  • local.ts holds the stack's addresses and ports for the stack, the app, the configs and the tests.

  • The app (src/app.ts, src/evm.ts):

    1. Connect an EIP-1193 wallet.
    2. Run the GitHub, X or Google ceremony through @libid/ceremony.
    3. Encode the accepted proof as the verifier's payload: TlsNotaryProof for GitHub and X, GoogleProof for Google.
    4. Simulate, send and confirm bind.
    5. Read the binding back from the registry and from the indexer.

    Each step has a data-testid, and one outcome field reports how the run ended: bound only when the registry and the indexer both report the run's holder.

  • e2e/bind.live.spec.ts (Playwright, pnpm -C ts/apps/bind test:live, one command: stack, app, tests, teardown):

    • A wallet that forwards to an unlocked anvil account, so no key enters the page.
    • One popup driver (e2e/popup.ts) runs each platform's pages: GitHub signs in with password and TOTP; X and Google restore a saved session and consent. It succeeds only when the popup reaches Bridge's callback; Chrome's error page or a popup closed early fails at once.
    • It waits for the page's outcome, then checks the proved account, transaction hash, receipt, registry holder and indexer holder.
    • LIBID_LIVE_PLATFORMS selects the platforms (all three by default). A platform's test is skipped without its secrets, unless LIBID_REQUIRE_LIVE is set, which fails it instead. LIBID_TEST_ENV_FILE can point at a dotenv file.

CI

.github/workflows/live-e2e.yml runs on pull requests that touch ts/, nightly and on dispatch, with LIBID_LIVE_PLATFORMS=github,x and LIBID_REQUIRE_LIVE. Google is left out: it challenges a saved session arriving from a runner's address.

  • Concurrency: each pull request has its own group, and a newer push cancels the older run. Scheduled and dispatched runs queue in one shared group.
  • Who runs it: forks and Dependabot get no secrets, so they skip it.
  • Logs: the stack's output (CCDP build, Compose and container logs, the deploy, the keeper) goes to a log file, not the public job log.
  • Failure artifact: screenshots, Playwright's error notes and that log, uploaded only after e2e/scan-secrets.ts finds no test-account secret in them. CI records no trace or video.
  • Budgets: the step's and the job's timeouts contain the stack start and both tests.

Result

All three pass locally in about 2.1 min; CI's GitHub and X selection in about 1.6 min. I checked a binding independently with cast: resolveId(github, id) returns the test wallet, the published handle is set, IdentityBound was emitted, and the indexer agrees.

Known CI failure

On the runner, the GitHub test sometimes fails with "Notarization request timed out". It failed at 29510f4 and fce3132 and passed at 86166bf. This is @libid/ceremony's 10 s budget in notary/session.ts, which runs from the request through the attestation. GitHub's sessions took 6.2–8.1 s on the runner, most of it in the reveal. X takes about 3 s and passes. The fix belongs in @libid/ceremony, as a separate budget for reveal through attestation, not a retry here.

Not yet

  • Negative tests (wrong holder → NotProofTarget).
  • Sharing the stack script, compose services and Bridge config with apps/dev, and publishing src/evm.ts's encoders from a package.

Integration friction found while building it

  1. @libid/ceremony, @libid/ledger and @libid/popup are private.
  2. There is no EVM LedgerId.
  3. There is no proof → bind payload encoder (it must be one ABI tuple).
  4. oauthProof omits the operation domain and transaction data, which the app must keep.
  5. The attestation field is signature in TS and proof in Solidity.
  6. The ceremony pins contracts 0.13.1, while the chain runs 0.15.0.
  7. The authorization digest helper is not exported.
  8. Verifier error ABIs are not exported, so AttestationAhead surfaced as an undecodable 0xa493bc37.
  9. The indexer still says owner/userId, not holder/id.
  10. Local chains must keep block time at the wall clock, which is undocumented.

src/evm.ts is a candidate to move into @libid/ledger and @libid/contracts.

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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
Keep both sides of the Biome ignore list, and regenerate the lockfile
from main's with the bind app's dependencies.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://feat-e2e-bind.previews.lib.id, https://feat-e2e-bind-libid.grounded-systems.workers.dev (commit 4b2f2c9)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://d52e8c8a.previews.lib.id, https://d52e8c8a-libid.grounded-systems.workers.dev 4b2f2c9 2026-10-05T10:03:06.055Z Visit the dashboard ↗

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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
- 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 <georglutsenko@gmail.com>
…arted 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 <georglutsenko@gmail.com>
- 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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
- 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 <georglutsenko@gmail.com>
- 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 <georglutsenko@gmail.com>
… 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 <georglutsenko@gmail.com>
- 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 <georglutsenko@gmail.com>
- 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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
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 <georglutsenko@gmail.com>
@SupremaLex SupremaLex changed the title test(bind): end-to-end GitHub binding from UI to chain test(bind): live end-to-end binding for GitHub, X and Google, from UI to chain Oct 6, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant