@connectx-sdk/core
v0.4.0
Published
Agent-first, chain-agnostic identity/signing SDK for ANP-02. One operation (sign / register / login) over any wallet configuration — ANP native or web3 (EVM / arbitrary chain).
Maintainers
Readme
connectx
Agent-first, chain-agnostic identity/signing SDK for ANP-02. One operation —
sign() / register() / login() — over any wallet configuration: an ANP-native
identity, a browser extension, an agent's local CLI key, or an arbitrary-chain
web3 wallet.
The key idea: an agent picks a wallet configuration, then does the same thing
whether that wallet is EVM, Solana, a browser extension, or ANP native. The SDK
doesn't just leave an interface seam for "ANP that happens to work with web3"
— it actually implements the layer that frames any { public key, private key,
signature } combination into an ANP-02 proof and verifies it back.
In the box: core (framework-agnostic) + react (hooks) + a scaffolding CLI (
@connectx-sdk/cli, theconnectxbinary). Server-side (agent-world) compatibility is a future direction — see Roadmap.
On the spec names — vNext splits authentication from the DID method, so this repo uses both. ANP-02 is the method-agnostic authentication layer (RFC 9421 HTTP Message Signatures +
content-digest+ nonce challenge + access token) — that is what this SDK implements and what "ANP-02" means below. ANP-03 is thedid:wbamethod rules (key-fingerprint binding, the DID document proof, the stable subject path) — still very much in play, just no longer the name for the auth flow. See ADR-0002 §2.1.
Install
npm install @connectx-sdk/core # runtime deps: @noble/curves, @noble/hashesreact is an optional peer dependency; import the react layer via @connectx-sdk/core/react.
To scaffold a starter project or drive agent identity from the CLI, install the separate CLI package (see Scaffolding):
npm install -D @connectx-sdk/cliCore concept
A connector is one wallet configuration. Everything about how that wallet signs is
sealed inside the connector; the caller only ever sees getIdentity() and
signRequest().
export interface AnpConnector {
id: string
name: string
type: WalletType // 'anp-extension' | 'anp-dev-shim' | 'anp-cli' | 'ethereum' | 'evm' | 'solana' | 'chain'
isAvailable(): Promise<boolean>
getIdentity(): Promise<AnpIdentity>
signRequest(req: SignRequest): Promise<SignResult> // pure, no network
}The uniform wallet interface — the seam that most wallets already satisfy:
export interface IWallet {
getPublicKey(): Promise<Uint8Array>
signMessage(message: Uint8Array): Promise<Uint8Array>
getKeypair?(): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }> // agent CLI only
curve?(): 'ed25519' | 'secp256k1'
}A browser wallet implements getPublicKey + signMessage. An agent's local CLI
key implements getKeypair. The SDK never cares which chain the key belongs to.
Two-layer identity (0.3.0) — AnpIdentity carries both halves of an identity:
export interface AnpIdentity {
subject: SubjectId // STABLE — survives a key update
did: string // the wire credential — carries the key fingerprint
keyid: string
publicKeyMultibase: string
didDocument: Record<string, unknown>
}A DID embeds the public-key fingerprint, so it changes when the key is updated. The
subject does not: subjectOf(did) derives it (did:wba:example.com:user:alice —
the same method and path, minus the fingerprint — or a CAIP-10 account id like
eip155:1:0x…, which is already a stable identity). Use the subject to store,
address and attribute an agent; use the DID to sign. Never put a subject on
the wire in place of the DID — vNext explicitly forbids reading "same stable
subject path" as "same subject" (that needs the continuity chain, which is Part F).
Usage
1. Build a config with connectors
import {
createConfig, selectWallet, signRequest, login,
anpDevShim, anpExtension, anpCli,
chainWalletConnector, keypairWallet, parsePrivateKey,
} from '@connectx-sdk/core'
const config = createConfig(
[
anpExtension(), // window.anp (browser)
anpDevShim('localhost:3000'), // dev identity (localStorage)
chainWalletConnector(
keypairWallet(myPrivateKey, { curve: 'ed25519' }),
{ host: 'localhost:3000', chainId: 'eip155:1' },
),
anpCli(parsePrivateKey(process.env.ANP_PRIVATE_KEY!), { host: 'localhost:3000' }),
],
'anp-dev-shim', // default
)2. Sign a login request (pure, no network)
const connector = selectWallet(config, 'anp-dev-shim')
const result = await signRequest(config, 'anp-dev-shim', {
url: 'https://host/api/login/anp/verify',
method: 'POST',
nonce,
body: JSON.stringify({ nonce, didDocument: await connector.getIdentity().then(i => i.didDocument) }),
})
// result.headers → content-digest, signature-input, signature3. Login (handles the 401 re-sign loop)
const outcome = await login(config, {
connectorId: 'anp-dev-shim',
nonce,
targetUri: 'https://host/api/login/anp/verify',
})
// outcome.did, outcome.token (access_token from Authentication-Info or body)login() does the whole dance: connector → getIdentity() → build body →
signRequest() → POST. If the server responds 401 with a
WWW-Authenticate: didwba nonce="..." challenge (nonce expired, or you started
without one), it re-signs with the server's nonce and re-POSTs, then reads
the access_token from Authentication-Info (or the body) into LoginOutcome.token.
4. Register a handle
await register(config, {
connectorId: 'anp-dev-shim',
handle: 'alice',
targetUri: 'https://host/api/register',
nonce,
})5. Arbitrary-chain wallet → ANP proof (the real "ANP works with web3")
import { chainWalletConnector, keypairWallet } from '@connectx-sdk/core'
const connector = chainWalletConnector(
keypairWallet(ed25519.utils.randomSecretKey(), { curve: 'ed25519' }),
{ host: 'localhost:3000' },
)This frames the wallet's { publicKey, privateKey, signature } into a normal
ANP-02 proof. A server verifying the request sees an ordinary ANP-02 signature —
it never knows the key came from an EVM/Solana/CLI wallet. cryptoWalletProver
is the bridge, and keypairWallet (or an EIP-1193 chainWallet) supplies the
key material.
API surface
权威清单见
src/core/index.ts(主入口,显式列出)与src/advanced.ts(@connectx-sdk/core/advanced,底层字节/编码/签名原语)。下表是概览; 底层原语(signAnpLoginRequest、buildContentDigestValue、utf8、base58*、encodeMultibase*、EVM 签名恢复等)从@connectx-sdk/core/advanced导入,不在主入口。
| Export | Purpose |
|---|---|
| createConfig(connectors, defaultId?) | Configure the SDK with connectors. |
| selectWallet(config, id?) | Pick a connector (by id or the default). |
| signRequest(config, id, req) | Pure signing; no network. |
| register(config, opts) | Sign + POST a registration request. |
| login(config, opts) | Full login incl. 401-challenge re-sign + token extraction. |
| connectors.anpExtension() | window.anp wallet extension. |
| connectors.anpDevShim(host, opts?) | Local dev identity; memory-only by default, opts.persist opts into localStorage. |
| connectors.anpCli(source, opts) | Agent local-key path (source = IWallet or a parsed key), opts.host required. |
| protocol.chainWalletConnector(wallet, opts) | Frame an IWallet as an ANP-02 connector. |
| protocol.keypairWallet(priv, opts) | Local keypair wallet (agent CLI key). |
| crypto.verifyHttpMessageSignature(...) | Verify a signed HTTP request (RFC 9421). |
| crypto.buildContentDigestValue(...) | sha-256=:...: Content-Digest value. |
| crypto.createAnpIdentity(host) | Deterministic Ed25519 identity + inline DID doc. |
| subjectOf(did) | Stable identity of a DID — the half that survives a key update, in DID shape (did:wba:example.com:user:alice, the did:web:… DID itself, or a CAIP-10 account id). |
| stableSubjectId(did) | The naked ANP-03 §2.2.3 stable subject path (example.com:user:alice) — exactly what a 409 did_superseded body reports. |
| crypto.signAnpLoginRequest(...) | Sign an ANP-02 login request (official branch). |
| crypto.resolveVerificationKey(keyid, doc?) | Resolve a DID doc's verification key. |
| crypto.resolveDidDocument(did, opts?) | Resolve a DID to its whole document (+ optional documentStore cache). |
| crypto.verifyDidDocument(doc, did, opts?) | Check a document you already hold: id binding + top-level proof. |
| crypto.createDidDocumentStore() | In-memory DidDocumentStore for resolveDidDocument's opt-in cache. |
| resolveDidChain(did, opts?) | Walk a did:wba update chain to the current DID, with a per-hop assurance grade (§2.5). Fails closed: a broken chain throws did_chain_broken rather than returning a shorter one. |
| createDidChainEdgeStore() | In-memory verified-edge store for resolveDidChain — conflict detection and evidence, not a way to skip re-verification. |
| login(opts.didChain, opts.onDidSuperseded) | Reactive 409 did_superseded retry (§3.2). Both must be set or no request is issued on a 409; the signer you hand back must be the DID our own chain walk verified, never the peer's currentDid hint. |
| advanced.signDidDocumentProofSync(...) | Sign a whole DID document — the retirement statement (deactivated: true + successorDid) an update is made of, signed by the old key. /advanced entry; before 0.3.0 no entry exposed it, so no external client could produce a chain hop at all. |
| advanced.verifyDidDocumentProofWithKey(doc, key) | Verify a document's proof against an explicitly supplied key. An update must use this (the key recorded at login), never verifyDidDocumentProof, which reads the key out of the document it is checking. |
| defineConfig(config) | Declarative config helper for connectx project generate (see Scaffolding). |
The react.ts module (import via @connectx-sdk/core/react) exports AnpProvider,
useStatus, useAccount, useConnect, useDisconnect, and useLogin — the
connection store + hooks that wrap the core operations for React.
Scaffolding
The @connectx-sdk/cli package ships a single connectx binary that
scaffolds starter projects and drives agent identity — a separate package from the
@connectx-sdk/core SDK library, so the runtime library stays dependency-free for the
browser. Commands:
| Command | What it does |
|---|---|
| connectx project init [name] | Interactive wizard (or --template flags) that scaffolds a working project. |
| connectx project generate | Read connectx.config.ts and emit a typed connectx.generated.ts. |
| connectx did … | The DID server half — create a key, show the stored identity, list, register a handle, resolve, update. The host and the DID arrive with did register — a did:wba DID names the server hosting it. DID protocol only, so no page there takes a wallet. |
| connectx anp … | The ANP server half — show the identity, log in to a site, sign a request. Takes a stored key or an EIP-1193 provider. |
connectx project init
npx @connectx-sdk/cli project init # interactive
npx @connectx-sdk/cli project init my-app --template node-agent --host localhost --connectors cli --no-installOr with the binary installed globally (npm i -g @connectx-sdk/cli), drop the
npx @connectx-sdk/cli prefix and run connectx project init.
Templates: react-vite (React + Vite), nextjs (App Router), node-agent
(headless agent script), vanilla (plain TS). Non-interactive flags
(--template --host --login-uri --connectors --pm --no-install) make it
fully scriptable for CI / agents.
connectx project generate
connectx.config.ts is a declarative description of your connectors, typed by
defineConfig:
// connectx.config.ts
import { defineConfig } from '@connectx-sdk/core'
export default defineConfig({
host: 'example.com',
connectors: { extension: true, devShim: { persist: true } },
login: { targetUri: 'https://example.com/api/login/anp/verify' },
})connectx project generate loads it (via jiti) and emits connectx.generated.ts — a
typed config plus loginAs<Connector> / signRequestAs<Connector> /
registerAs<Connector> wrappers keyed by connector id, so callers stop passing
string connector ids:
import { config, loginAsExtension } from './connectx.generated'
const outcome = await loginAsExtension({ nonce })Architecture
src/
index.ts # core barrel (agent-facing API)
react.ts # react layer: AnpProvider + hooks (useStatus/useAccount/useConnect/useLogin)
core/
types.ts # AnpConnector · AnpConfig · SignRequest/Result · LoginOutcome · WalletType
config.ts # createConfig + selectWallet
actions.ts # signRequest() · register() · login() (uniform ops)
crypto/ # isomorphic primitives (browser + Node 22+)
signatureBase · multicodec · encoding · base58 · signer · verify · resolveDid
wallet/ # chain-agnostic wallet interface
wallet.ts # IWallet: getPublicKey / signMessage / getKeypair?
eip1193.ts # reference connector: window.ethereum personal_sign
protocol/ # protocol framing (proof abstraction + ANP-native + chain adapter)
proof.ts # Proof / ProofAdapter / WalletProver
anp.ts # official ANP-02 proof (RFC 9421) + buildWalletIdentity + cryptoWalletProver
adapters/chain.ts# frame { public key + signature } into an ANP proof (chain-agnostic)
connectors/
anpExtension.ts # reads window.anp
anpDevShim.ts # localStorage identity
anpCli.ts # agent local-key path (key file / mnemonic / keystore / PEM)Design principles
- One operation, agent's view.
sign()/register()/login()are uniform; no parameter or return branches on "is this ANP or web3". Wallet selection = onecreateConfig+selectWallet(), after which every wallet flows identically. - Connector = one wallet configuration.
AnpConnectoris the abstraction; wallet-specific signing is sealed inside.anpExtension,anpDevShim,anpCli, andwallet.ethereum()-style connectors all expose the same three methods. - Chain-agnostic wallet interface. Only
public key + private key + signatureare required. No chain-specific address recovery (ecrecover) — the SDK relies on generic signature verification. - “ANP composes with web3” is real, not a seam.
protocol/adapters/chain.tsgenuinely frames any{ public key + signature }into an ANP-02 proof and verifies it. It's not a reserved interface. - Client-focused, wagmi-like. The SDK exports isomorphic verification primitives; session management is the app/server's job.
Verification
npm run typecheck # tsc --noEmit, clean
npm run build # tsup → dist/ + .d.ts / .d.cts (ESM + CJS)
npm test # vitest, jsdom (full suite incl. react)
npm run test:node # core tests under the `node` environment (proves isomorphism)
npm run test:types # tsd — signature assertions on the public .d.ts
npm run check:types # attw — package type-resolution correctness (node16 + bundler)
npm run check:browser # scan dist ESM entries for Node-only APIs
npm run build:cli # build @connectx-sdk/cli → packages/cli/dist/cli.js
npm run test:cli # vitest — CLI codegen/validation unit tests
node scripts/cli-e2e.mjs # CLI login vs a mock server (401 re-sign + token)
node scripts/demo-e2e.mjs # scaffolded demo vs a live agent-on (login+register closure)The SDK is self-consistent: signs with the official ANP-02 branch and verifies
with its own verifyHttpMessageSignature. A smoke test signs a request with the
official branch and verifies it back — the closed loop must PASS.
The dual ESM/CJS package ships correct types for both: exports nests types
under import (.d.ts) and require (.d.cts), so CJS consumers no longer
"masquerade as ESM". Consumers must use a modern module resolution — bundler,
node16, or nodenext; legacy node10 (moduleResolution: "node") cannot
resolve the @connectx-sdk/core/react / @connectx-sdk/core/advanced subpaths (an inherent limit of
exports-based packages).
Roadmap
Future directions, not implemented here:
- Signature proxy (
A3.1). Let an agent never touch private-key bytes by delegating signing to a separate local process. - Identity registration (
A3.2). Bind identity creation to a site's registration flow and record the registered state in the store. - MCP server (
A1.4). Wrap the CLI as an MCP tool surface for agents that speak MCP instead of shell. - DID restore (
restore()). Theprovider_assertedtier (the "lost my old key, restore via the provider" path, ADR-0002 §2.4) is already modelled and enforced byresolveDidChain; what's missing is the server-side endpoint, which vNext deliberately leaves to the server.restore()will be a thin adapter overresolveDidChainonce that shape is agreed — writing one now would freeze a guessed protocol into the public contract.
create-connectx / npm create connectx is not planned — the first command
stays npx @connectx-sdk/cli init. See todos/TODO2.md for the reasoning.
Reference server: agent-on
A working ANP-02 server lives outside this repo at ../agent-on (a Next.js app).
It consumes the SDK's server-side verification (resolveVerificationKey +
verifyHttpMessageSignature) and shares the token design the SDK CLI expects:
- Agent login returns a token. After
login()/register()the SDK reads the server'sAuthentication-Info: access_token="<jwt>"header (or{ token }body) intoLoginOutcome.token. It's an HS256 JWT (sub= the agent's full DID, TTL 7d) — the server stores only its SHA-256 hash. - Use that token as a Bearer credential. Subsequent agent requests carry
Authorization: Bearer <token>;agent-onresolves it back to the identity (e.g. its/api/mereturns{ identity: { did } }). Unauthenticated or forged tokens are rejected with401+WWW-Authenticate: Bearer realm="agent-on". agent-onis a separate project (not part ofconnectx); the demo is aconnectx project init --template node-agentproject pointed at it, so "agent login → token → Bearer" is exercised end to end.- The DID-server role is gone (TODO7 9.10).
agent-onused to serve thedid.jsonof every DID it onboarded — a hosteddid:wbais only usable if resolvers can fetch its document, and a retired DID's document is what made an update chain walkable — and to publish chain hops atPOST /api/did/rotate(a retirement document signed by the old key plus the successor's active document; no session needed, the retirement proof is the authorization, and the account followed the key so the stable subject survived the update). Both are removed, along with the409 did_supersededbranches that went with them: hosting DIDs is a DID server's job, not an application server's. The SDK consumes that contract (login(opts.didChain, opts.onDidSuperseded)); it does not require this server to implement it.
Not in scope
- Modifying agent-world (its components, routes, server, or localStorage keys).
- Server session management (mintNonce / cookie / consumeNonce store) — the SDK only exports verification primitives.
- No ethers / chain-specific ecrecover; only
@noble/curves.
License
MIT
