feat(identity): add /identity schemas subpath for dual-sig auth#31
feat(identity): add /identity schemas subpath for dual-sig auth#31whoabuddy wants to merge 1 commit into
Conversation
Adds @aibtc/tx-schemas/identity with shared zod schemas for the dual-signature auth flow that's currently duplicated across 8+ sites (landing-page, agent-news, x402-sponsor-relay, x402-api): - challenge envelope (Challenge, ChallengeStoreRecord, ChallengeResponse) - BIP-137 + BIP-322 Bitcoin signature payload + address kinds - SIP-018 Stacks signature payload + domain - dual-sig claim and resolved address-pair - BTC and STX request auth headers These are pure schemas — no runtime/Cloudflare deps. The matching verifier implementations land in @aibtc/platform/auth (separate repo, to be created in Phase 0 of the cost-fix sprint). Refs: cloudflare-bill-audit-2026-04.md (D4, Phase 1) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Introduces a new @aibtc/tx-schemas/identity subpath that centralizes Zod schemas for the dual-signature auth flow (challenge envelopes, BTC/Stacks signature payloads, dual-sig claim, and request auth headers) so downstream services can share a single schema source.
Changes:
- Added
src/identity/*schemas (challenge, bitcoin signature payload, stacks signature payload, dual-sig claim/address pair, auth headers) and an identity barrel export. - Exposed the new identity subpath via package root exports (
src/index.ts) andpackage.jsonexportsentries. - Added Vitest coverage for the new identity schemas.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/identity/identity.test.ts | Adds tests covering the new identity schemas’ basic acceptance/rejection cases. |
| src/index.ts | Re-exports identity subpath from the package root. |
| src/identity/challenge.ts | Adds challenge, stored challenge record, and challenge response schemas. |
| src/identity/bitcoin-signature.ts | Adds BTC signature payload + scheme/address-kind enums. |
| src/identity/stacks-signature.ts | Adds SIP-018 domain + Stacks signature payload schemas. |
| src/identity/dual-sig.ts | Adds dual-sig claim schema and resolved {btcAddress, stxAddress} pair schema. |
| src/identity/auth-headers.ts | Adds request auth-header payload schemas for BTC and STX auth. |
| src/identity/index.ts | Identity subpath barrel exports. |
| package.json | Adds exports mappings for ./identity and its leaf modules. |
| README.md | Documents the new @aibtc/tx-schemas/identity subpath. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export const ChallengeSchema = z.object({ | ||
| message: z.string().min(1), | ||
| expiresAt: z.string().datetime({ offset: true }), | ||
| action: z.string().min(1), | ||
| }); |
There was a problem hiding this comment.
Use the shared IsoDateTimeSchema from src/core/primitives instead of repeating z.string().datetime({ offset: true }) here (and for createdAt below). This keeps datetime validation/error messages consistent across subpaths and makes future changes (e.g., tighter datetime rules) apply everywhere automatically.
| export const ChallengeStoreRecordSchema = ChallengeSchema.extend({ | ||
| createdAt: z.string().datetime({ offset: true }), | ||
| }); |
There was a problem hiding this comment.
ChallengeStoreRecordSchema should also reuse the shared IsoDateTimeSchema (from src/core/primitives) for createdAt to match the datetime convention used throughout core/http/rpc/news schemas.
| export const StacksSignaturePayloadSchema = z.object({ | ||
| domain: Sip018DomainSchema, | ||
| message: z.string().min(1), | ||
| signature: z.string().min(1), | ||
| stxAddress: z.string().min(1), | ||
| }); |
There was a problem hiding this comment.
stxAddress should use the shared StacksAddressSchema from src/core/primitives rather than z.string().min(1). This keeps identity schemas aligned with the rest of the package (http/rpc/core all use StacksAddressSchema) and allows central tightening of address validation later without touching identity schemas.
| export const AddressPairSchema = z.object({ | ||
| btcAddress: z.string().min(1), | ||
| stxAddress: z.string().min(1), | ||
| }); |
There was a problem hiding this comment.
AddressPairSchema should use the shared StacksAddressSchema for stxAddress (and consider a shared BTC address schema if one exists later) rather than z.string().min(1), to stay consistent with how Stacks addresses are validated elsewhere in this package.
| export const BitcoinAuthHeadersSchema = z.object({ | ||
| address: z.string().min(1), | ||
| signature: z.string().min(1), | ||
| /** Unix seconds, as a string (HTTP header value) */ | ||
| timestamp: z.string().regex(/^\d+$/), |
There was a problem hiding this comment.
JSDoc describes X-BTC-Signature as base64, but signature is currently only validated as non-empty. Either add base64 validation (so the schema matches the documented contract) or update the docs to clarify this is an opaque string.
| * `X-STX-Domain` (JSON-encoded SIP-018 domain). | ||
| */ | ||
| export const StacksAuthHeadersSchema = z.object({ | ||
| stxAddress: z.string().min(1), | ||
| signature: z.string().min(1), | ||
| timestamp: z.string().regex(/^\d+$/), | ||
| /** JSON-encoded Sip018Domain — verifier parses + checks against expected */ |
There was a problem hiding this comment.
The comment says domain is a JSON-encoded SIP-018 domain, but the schema only enforces min(1). Consider validating that this string is valid JSON and conforms to Sip018DomainSchema (or rename/update docs to indicate it’s an unvalidated raw header value).
| * `X-STX-Domain` (JSON-encoded SIP-018 domain). | |
| */ | |
| export const StacksAuthHeadersSchema = z.object({ | |
| stxAddress: z.string().min(1), | |
| signature: z.string().min(1), | |
| timestamp: z.string().regex(/^\d+$/), | |
| /** JSON-encoded Sip018Domain — verifier parses + checks against expected */ | |
| * `X-STX-Domain` (raw header value containing a JSON-encoded SIP-018 domain). | |
| */ | |
| export const StacksAuthHeadersSchema = z.object({ | |
| stxAddress: z.string().min(1), | |
| signature: z.string().min(1), | |
| timestamp: z.string().regex(/^\d+$/), | |
| /** Raw `X-STX-Domain` header value; verifier parses JSON and validates the SIP-018 domain separately */ |
arc0btc
left a comment
There was a problem hiding this comment.
Adds a centralized `@aibtc/tx-schemas/identity` subpath for the BIP-322/BIP-137 + SIP-018 dual-sig auth flow — a prerequisite the `@aibtc/platform/auth` migration needs before it can consolidate verifiers across landing-page, agent-news, and the relay.
What works well:
- Consistent naming pattern (`FooBarSchema` + `FooBar` type) matching the rest of the repo
- Both subpath index and leaf exports in `package.json` allow selective tree-shaking
- `BitcoinAuthHeadersSchema.timestamp` regex (`/^\d+$/`) correctly enforces a numeric string — we've seen non-numeric timestamp headers slip through in older services
- `p2tr` included in `BitcoinAddressKindSchema` with a "reserved" comment is the right call: valid enum value today, production-ready later
- Test coverage is solid — happy paths + rejection cases for all schemas, and the import paths use `.js` extensions for ESM correctness
[question] `DualSigClaimSchema` has no shared action field (`src/identity/dual-sig.ts:13-19`)
The PR description says "both signatures verify and reference the same action" — but the schema doesn't capture that linkage. The BTC side has an independent `message`, the STX side has an independent `message`, and nothing ties them together at the data layer. The verifier in `@aibtc/platform/auth` presumably enforces that both messages cover the same action by convention.
Is this intentional? The risk is that call sites assemble `DualSigClaim` objects with mismatched BTC/STX messages and get a runtime verification failure with no schema-level hint about why. Worth considering a top-level `action` field:
```suggestion
export const DualSigClaimSchema = z.object({
action: z.string().min(1),
bitcoin: BitcoinSignaturePayloadSchema,
stacks: StacksSignaturePayloadSchema,
});
```
Happy to leave this as a follow-up if the existing migration sites don't have the field yet — but flagging it as a future footgun.
[suggestion] `scheme` optional in `BitcoinSignaturePayloadSchema` (`src/identity/bitcoin-signature.ts:43`)
The optional scheme is pragmatic (BIP-322 and BIP-137 can be auto-detected). We use BIP-137 for AIBTC inbox signing from `bc1q` addresses and have hit verifier confusion when the scheme wasn't set. Consider documenting the detection heuristic in a comment so call sites know what "absent" means at the verifier end — or encouraging callers to always set it.
[nit] `StacksAuthHeadersSchema.domain` accepts any non-empty string, but the comment says "verifier parses + checks against expected" — this is fine for transport, but a JSDoc type cross-reference (`@see Sip018DomainSchema`) would help implementors know what to parse it into.
Code quality notes:
- No obvious reuse gaps — the subpath structure mirrors `/rpc`, `/http`, `/news` exactly
- Comments are more detailed than typical for this repo, but given this is a shared auth contract across 8+ sites, the extra context is earned
- No dead code or unused imports
Operational context: We sign AIBTC inbox messages with BIP-137 from our `bc1q` wallet (`p2wpkh`). The `SIP-018` domain pattern in `StacksSignaturePayloadSchema` matches what we observe in x402 sponsor relay auth calls. The JSON-encoded `domain` string in `StacksAuthHeadersSchema` is consistent with how the relay currently passes domain context via headers — this schema will let us validate those payloads rather than accepting raw strings.
Summary
@aibtc/tx-schemas/identitysubpath with zod schemas for the dual-signature auth flow currently duplicated across 8+ sites in landing-page, agent-news, x402-sponsor-relay, and x402-api.@aibtc/platform/auth(separate repo, created in Phase 0 of the cost-fix sprint).What's in the subpath
Challenge,ChallengeStoreRecord,ChallengeResponse(matches landing-page/lib/challenge.ts shape)Sip018Domain,StacksNetwork)DualSigClaimand resolvedAddressPairBitcoinAuthHeadersandStacksAuthHeadersfor request-level authWhy now
Per
cloudflare-bill-audit-2026-04.mdSection 5 / D4: the@aibtc/platform/authmigration plan needs the identity schemas in place first. Landing-page, agent-news, and the relay all carry their own copies of the BIP-322/SIP-018 payload shapes today; codifying the schema is a prerequisite to consolidating the verifier implementations.This PR is intentionally landing on
@aibtc/tx-schemas(the existing package) rather than waiting for the rename to@aibtc/schemas. When the rename PR ships, this subpath moves cleanly.Test plan
npm run typecheckcleannpm test— 241 tests pass (14 new identity tests + 227 existing)npm run buildemits all subpath dist filesnpm pack --dry-runincludesdist/identity/*🤖 Generated with Claude Code