logicsrc/docs/ans-sdk.md
Anthony Ettinger 0ae24719e3 docs(ans): spec the @logicsrc/ans TypeScript SDK
Design doc for the LogicSRC TS SDK for the Agent Name Service (ANS): resolver +
offline transparency-log verifier + registration client + coinpay-DID bridge.
Covers scope, package layout, full API surface, the ans-verify algorithm,
milestones (M1 verifier -> M2 register -> M3 DID bridge), and risks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:41:00 +00:00

294 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ANS SDK (`@logicsrc/ans`)
`@logicsrc/ans` is the LogicSRC TypeScript SDK for the
[Agent Name Service](https://github.com/agentnameservice) (ANS): the JS/TS
client + **offline verifier** for resolving an agent *name* to a verifiable,
versioned identity.
ANS is "DNS for agents": where DNS resolves a domain to an address, ANS resolves
an agent name (`ans://v1.0.0.my-agent.example.com`) to a cryptographic identity,
anchored to domain ownership (DNS/ACME) and backed by a private CA plus an
append-only **transparency log** (SCITT/COSE receipts, RFC 9162 / RFC 6962).
Upstream ANS ships SDKs for Go, Java, and Rust — **but not JavaScript/TypeScript**,
which is the language of the agent/MCP/web ecosystem (LogicSRC, sh1pt, AgentBBS).
This SDK fills that gap and makes ANS a first-class identity source alongside the
existing LogicSRC DID model.
## Why this lives in LogicSRC
- **No upstream TS SDK.** Go/Java/Rust only. This is a clean, reusable OSS
artifact and a first-mover contribution.
- **LogicSRC is already an identity layer.** Identity here is a DID (via the
`coinpay` plugin's `did.auth`). ANS is the *naming + domain-anchored
verification* layer that DIDs lack — they are complementary, not competing
(see [DID bridge](#did-bridge)).
- **Multiple in-house consumers.** [AgentGit](./agentgit.md) members, the
`sh1pt` `registry-ans` ship target, AgentBBS join-time verification, and
`commandboard` discovery can all consume one SDK.
The split, stated once:
- **ANS answers**: *what is this agent's canonical name, is it really it, and
which version/endpoint?* — discovery + domain-anchored verification.
- **LogicSRC DID answers**: *is this a portable identity I can authorize, pay,
and score?* — sovereign identity + reputation + payment rails.
## Scope
In scope for the SDK:
1. **Resolver**`ans://` name → `AnsIdentity` (cert chain, endpoint, version,
lifecycle events) via the registry HTTP API.
2. **Offline verifier** — cryptographically verify a resolution against the
transparency log **without trusting the operator** beyond advertised root
keys. This is the hard, high-value part and ports the `ans-verify` semantics
to TS.
3. **Registration client** — open a registration, drive a domain-ownership
challenge (DNS-01 / ACME), and read back the issued identity + receipt.
4. **DID bridge** — map between an `ans://` name and a LogicSRC/`coinpay` DID.
Out of scope (delegated, not reimplemented):
- Running a registry or transparency log (that's the upstream Go `ans` server).
- DNS record application — delegated to a DNS provider. In sh1pt that's its DNS
adapters; in LogicSRC the caller supplies a `DnsApplier` (see
[Registration](#registration)).
- Certificate issuance / the private CA (server-side).
## Architecture
```txt
@logicsrc/ans (this package)
┌──────────────────────────────────────────┐
│ AnsClient │
│ ├─ resolve(name) ── registry HTTP ──┼──► ANS registry
│ ├─ register(req) ── registry HTTP ──┼──► (Go `ans` server)
│ └─ rootKeys() ── registry HTTP ──┘
│ │
│ Verifier (offline, pure) │
│ ├─ verifyReceipt(receipt, rootKeys) │ COSE_Sign1 + Merkle proof
│ └─ verifyResolution(identity, opts) │ (no network)
│ │
│ DidBridge │
│ ├─ ansNameForDid(did) │
│ └─ didForAnsName(name) │
└──────────────────────────────────────────┘
▲ ▲
│ consumed by │
sh1pt registry-ans AgentGit / AgentBBS / commandboard
```
The **Verifier is pure and dependency-light** (crypto + CBOR/COSE only, no
`fetch`), so it runs in Node, Deno, Bun, edge runtimes, and the browser, and is
trivially unit-testable with fixtures captured from the upstream Go server.
## Capabilities
```txt
name.resolve resolve an ans:// name to an identity
name.verify offline-verify a resolution against the transparency log
name.register open a registration + domain-ownership challenge
name.status poll a pending registration / verification
receipt.verify verify a SCITT COSE_Sign1 inclusion receipt
rootkeys.fetch fetch + parse the registry root-keys (sumdb-note)
did.bind bind an ans:// name to a LogicSRC DID
did.resolve resolve a DID to its ans:// name (and back)
```
## Package layout
```txt
packages/ans/
package.json @logicsrc/ans (ESM, tsc build, vitest)
tsconfig.json extends ../../tsconfig.base.json
src/
index.ts public exports
types.ts AnsName, AnsIdentity, Receipt, RootKeys, …
name.ts parse/format ans:// names (zod-validated)
client.ts AnsClient — registry HTTP (resolve/register/status)
verify/
receipt.ts COSE_Sign1 parse + ES256 verify
merkle.ts RFC 6962 leaf hash + inclusion-proof walk
rootkeys.ts sumdb-note root-keys parser + kid→key map
index.ts verifyReceipt(), verifyResolution()
did.ts DidBridge (ANS ↔ coinpay DID)
index.test.ts unit tests (fixtures/ from upstream Go server)
fixtures/ captured receipts, root-keys, resolutions
```
`@logicsrc/ans` is a **leaf package** (like `@logicsrc/sdk`): it depends only on
crypto/CBOR libraries and `@logicsrc/schemas` for shared types. The `coinpay`
DID coupling stays behind a small injected interface so the verifier core has no
LogicSRC dependency and could be published standalone.
## Public API (TypeScript surface)
```ts
// ── names ───────────────────────────────────────────────────────────
/** ans://v<semver>.<agent>.<domain> */
export interface AnsName {
raw: string; // "ans://v1.0.0.my-agent.example.com"
version: string; // "1.0.0"
agent: string; // "my-agent"
domain: string; // "example.com"
}
export function parseAnsName(raw: string): AnsName; // throws on malformed
export function formatAnsName(parts: Omit<AnsName, 'raw'>): string;
// ── identity / receipts ─────────────────────────────────────────────
export interface AnsIdentity {
name: AnsName;
endpoint?: string; // advertised agent endpoint
capabilities: string[];
certChainPem: string; // identity cert (private-CA signed, mTLS)
serverCertTlsa?: string; // optional BYOC pinned TLSA
events: LifecycleEvent[]; // from the transparency log
receipt: Receipt; // SCITT COSE_Sign1 inclusion receipt
}
export interface LifecycleEvent { type: string; at: string; payload?: unknown; }
export interface Receipt { cbor: Uint8Array; } // raw COSE_Sign1 bytes
export interface RootKeys { keys: Map<string /*4-byte kid hex*/, CryptoKey>; }
// ── client (network) ────────────────────────────────────────────────
export interface AnsClientOptions {
registryUrl: string; // e.g. https://registry.ans.dev
token?: string; // for register/status
pinnedRootKeysPem?: string; // skip /root-keys; trust this instead
fetch?: typeof fetch; // injectable for tests/edge
}
export class AnsClient {
constructor(opts: AnsClientOptions);
resolve(name: string | AnsName): Promise<AnsIdentity>;
rootKeys(): Promise<RootKeys>;
register(req: RegisterRequest): Promise<Registration>;
status(name: string | AnsName): Promise<RegistrationStatus>;
}
// ── verifier (offline, pure, no network) ────────────────────────────
export interface VerifyOptions { rootKeys: RootKeys; now?: Date; }
export interface VerifyResult { ok: boolean; reason?: string; rootHashHex: string; }
export function verifyReceipt(receipt: Receipt, opts: VerifyOptions): Promise<VerifyResult>;
export function verifyResolution(id: AnsIdentity, opts: VerifyOptions): Promise<VerifyResult>;
// ── registration ────────────────────────────────────────────────────
export interface RegisterRequest {
agent: string; domain: string; version: string;
endpoint?: string; capabilities?: string[];
verify: 'dns' | 'acme';
dns?: DnsApplier; // when set, SDK applies the challenge
}
/** Caller-supplied DNS automation (e.g. a sh1pt DNS adapter). */
export interface DnsApplier {
upsertTxt(record: { name: string; value: string }): Promise<void>;
}
export interface Registration { name: AnsName; challenge: { type: 'TXT'; name: string; value: string }; }
export interface RegistrationStatus { state: 'pending' | 'verifying' | 'live' | 'failed'; message?: string; }
// ── DID bridge ──────────────────────────────────────────────────────
export interface DidBridge {
ansNameForDid(did: string): Promise<AnsName | null>;
didForAnsName(name: string | AnsName): Promise<string | null>;
}
```
### Usage sketches
```ts
// Resolve + verify (the common path; trustless)
const ans = new AnsClient({ registryUrl: 'https://registry.ans.dev' });
const id = await ans.resolve('ans://v1.0.0.my-agent.example.com');
const { ok } = await verifyResolution(id, { rootKeys: await ans.rootKeys() });
if (!ok) throw new Error('unverified agent identity');
// Register with automated DNS (DNS applier supplied by the caller, e.g. sh1pt)
const reg = await ans.register({
agent: 'my-agent', domain: 'example.com', version: '1.0.0',
endpoint: 'https://my-agent.example.com', verify: 'dns',
dns: { upsertTxt: ({ name, value }) => dnsAdapter.upsertTxt(name, value) },
});
```
## Verification algorithm
Ports the upstream `ans-verify` flow. Pure functions over bytes; the only trust
input is the root keys (fetched once, or pinned):
1. Obtain root keys: parse `/root-keys` (sumdb-note format) **or** the pinned
PEM. Build a `kid (4-byte) → verifier key` map.
2. Parse the receipt as `COSE_Sign1` (RFC 8152 tag 18, ES256).
3. Extract the Merkle inclusion proof + leaf payload from the protected/unprotected
headers.
4. Compute the leaf hash via RFC 6962: `SHA-256(0x00 || payload)`.
5. Walk the Merkle path from the leaf hash to the claimed root hash.
6. ES256-verify the COSE `Sig_structure` signature using the `kid`-mapped key.
7. Cross-check leaf-hash consistency and the resolved identity binding (name,
cert, lifecycle).
`verifyResolution()` wires the resolved `AnsIdentity` through steps 27 and also
checks the cert chain binds to the resolved `domain`.
## DID bridge
The bridge is where ANS and the LogicSRC/`coinpay` DID model meet — directly the
"CoinPay DID ↔ ANS" question.
- **`did:web` under a verified ANS domain.** Once a name's domain is ANS-verified,
the agent's `coinpay` DID can be published as `did:web:<domain>:<agent>` and
resolved from the same anchor. ANS provides the discoverable human-readable
name + transparency proof; the DID provides the portable identity + reputation
receipts + payment rails.
- **Binding direction.** `did.bind` records the `ans://` ↔ DID mapping (as an ANS
lifecycle event and/or a `coinpay` DID service entry). `DidBridge` reads it both
ways so AgentGit can keep authenticating with a DID while exposing a verifiable
ANS name to the outside world.
- **No DID minting here.** The SDK never issues DIDs (that's `coinpay`) and never
issues ANS certs (that's the registry). It only *binds* and *resolves*.
## Milestones
- **M1 — Resolver + offline verifier.** `parseAnsName`, `AnsClient.resolve`,
`rootKeys`, `verifyReceipt`/`verifyResolution`, fixtures from the upstream Go
server. This is the standalone-publishable core and unblocks read-side
consumers.
- **M2 — Registration client.** `register`/`status` + the `DnsApplier` hook.
Lets the sh1pt `registry-ans` target complete its `TODO(M2)` (apply challenge,
verify, poll receipt) by delegating to this SDK instead of hand-rolled `fetch`.
- **M3 — DID bridge.** `DidBridge` + `did:web` publication under the verified
domain, wired through the `coinpay` plugin. Gate on M1+M2 and on how far the
upstream IETF draft has stabilized.
## Dependencies & testing
- **Crypto/CBOR:** prefer WebCrypto (`crypto.subtle`, ES256) for portability;
a minimal COSE/CBOR decoder (e.g. `cbor-x` or a vendored decoder) for
`COSE_Sign1`. Keep the verifier free of Node-only APIs so it runs on edge and
in the browser.
- **Validation:** `zod` for name + wire-shape parsing (matches LogicSRC schema
conventions; consider emitting the shapes into `@logicsrc/schemas`).
- **Tests:** capture real `/root-keys`, resolutions, and receipts from a local
upstream `ans` server into `fixtures/`; unit-test the verifier against them
(happy path + tampered-payload, wrong-kid, bad-proof, expired-cert negatives).
Verifier is `vitest run src` like the other packages, with no network.
## Risks / open questions
- **Draft-stage standard.** ANS wire formats (receipt headers, root-keys note)
may shift; pin to a server commit for fixtures and version the SDK against it.
- **More centralized than DIDs.** ANS uses a registry + private CA. Treat ANS as
*naming/discovery/verification* and keep sovereign identity in the DID layer;
do not let ANS become the system of record for identity.
- **CBOR/COSE surface in TS.** No single blessed lib; the verifier's COSE_Sign1
handling is the main implementation risk — keep it small, vendored if needed,
and fixture-driven.
- **Trust bootstrap.** `pinnedRootKeysPem` vs `/root-keys` is a real trust
decision; default to pinning for in-house consumers (AgentGit/AgentBBS) and
document the TOFU tradeoff for `/root-keys`.
## First consumers
- **sh1pt `registry-ans` target** — replaces its hand-rolled `fetch` register
call and completes M2 verification by depending on `@logicsrc/ans`.
- **AgentGit / AgentBBS** — verify an agent's `ans://` name at join/merge time
alongside the existing DID auth.
- **commandboard discovery** — resolve + verify advertised agent endpoints.