From af7df7c18dbc7e97383580f69008ce1ffb0631b2 Mon Sep 17 00:00:00 2001 From: Tim Nunamak Date: Wed, 13 May 2026 17:58:18 -0500 Subject: [PATCH 1/8] feat(protocol): add personal server registration signing --- .../personal-server-registration.test.ts | 234 ++++++++++++++++++ .../account/personal-server-registration.ts | 226 +++++++++++++++++ packages/vana-sdk/src/index.browser.ts | 29 +++ packages/vana-sdk/src/index.node.ts | 29 +++ packages/vana-sdk/src/protocol/eip712.ts | 2 +- .../personal-server-registration.test.ts | 224 +++++++++++++++++ .../protocol/personal-server-registration.ts | 207 ++++++++++++++++ 7 files changed, 950 insertions(+), 1 deletion(-) create mode 100644 packages/vana-sdk/src/account/personal-server-registration.test.ts create mode 100644 packages/vana-sdk/src/account/personal-server-registration.ts create mode 100644 packages/vana-sdk/src/protocol/personal-server-registration.test.ts create mode 100644 packages/vana-sdk/src/protocol/personal-server-registration.ts diff --git a/packages/vana-sdk/src/account/personal-server-registration.test.ts b/packages/vana-sdk/src/account/personal-server-registration.test.ts new file mode 100644 index 00000000..91b703fe --- /dev/null +++ b/packages/vana-sdk/src/account/personal-server-registration.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it, vi } from "vitest"; +import { + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + PERSONAL_SERVER_REGISTRATION_INTENT, + buildPersonalServerRegistrationTypedData, + type PersonalServerRegistrationSigner, +} from "../protocol/personal-server-registration"; +import { signPersonalServerRegistrationWithAccount } from "./personal-server-registration"; + +const ACCOUNT_ORIGIN = "https://account.app-dev.example"; +const OWNER_ADDRESS = "0x1111111111111111111111111111111111111111"; +const SERVER_ADDRESS = "0x2222222222222222222222222222222222222222"; +const SERVER_PUBLIC_KEY = "did:key:z6MkiPersonalServerPublicKey"; +const SERVER_URL = "https://ps.example.com"; +const SIGNATURE = `0x${"aa".repeat(65)}` as const; + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +describe("Account Personal Server registration integration", () => { + it("returns the SDK signed result when Account silently signs", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "signed", + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + }), + ); + + const result = await signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ); + + expect(fetchImpl).toHaveBeenCalledWith( + new URL( + "/api/v1/intents/personal-server-registration/sign", + `${ACCOUNT_ORIGIN}/`, + ), + expect.objectContaining({ + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + intent: PERSONAL_SERVER_REGISTRATION_INTENT, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }), + }), + ); + expect(result).toEqual({ + status: "signed", + result: { + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + typedData: buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }), + intent: PERSONAL_SERVER_REGISTRATION_INTENT, + }, + }); + }); + + it("returns confirmation-required typed data without hiding fallback semantics", async () => { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + config: { + chainId: 1480, + contracts: { + dataRegistry: PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + dataPortabilityPermissions: + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + dataPortabilityServer: + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + dataPortabilityGrantees: + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + }, + }, + }); + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "confirmation_required", + signerAddress: OWNER_ADDRESS, + typedData, + }), + ); + + await expect( + signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ), + ).resolves.toEqual({ + status: "confirmation_required", + signerAddress: OWNER_ADDRESS, + typedData, + }); + }); + + it("normalizes the current experimental silent-sign response shape", async () => { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "fallback_required", + signer: { address: OWNER_ADDRESS }, + typed_data: typedData, + }), + ); + + await expect( + signPersonalServerRegistrationWithAccount( + { + accountOrigin: ACCOUNT_ORIGIN, + endpointPath: "/api/experimental/silent-sign", + fetchImpl, + }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ), + ).resolves.toEqual({ + status: "confirmation_required", + signerAddress: OWNER_ADDRESS, + typedData, + }); + }); + + it("normalizes experimental signed responses", async () => { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "signed", + signature: SIGNATURE, + signer: { address: OWNER_ADDRESS }, + typed_data: typedData, + }), + ); + + await expect( + signPersonalServerRegistrationWithAccount( + { + accountOrigin: ACCOUNT_ORIGIN, + endpointPath: "/api/experimental/silent-sign", + fetchImpl, + }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ), + ).resolves.toEqual({ + status: "signed", + result: { + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + typedData, + intent: PERSONAL_SERVER_REGISTRATION_INTENT, + }, + }); + }); + + it("uses a fallback signer for returned confirmation typed data when provided", async () => { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + const fallbackSigner: PersonalServerRegistrationSigner = { + address: OWNER_ADDRESS, + signTypedData: vi.fn().mockResolvedValue(SIGNATURE), + }; + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "confirmation_required", + typedData, + }), + ); + + const result = await signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl, fallbackSigner }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ); + + expect(fallbackSigner.signTypedData).toHaveBeenCalledWith(typedData); + expect(result).toEqual({ + status: "fallback_signed", + accountStatus: "confirmation_required", + result: { + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + typedData, + intent: PERSONAL_SERVER_REGISTRATION_INTENT, + }, + }); + }); +}); diff --git a/packages/vana-sdk/src/account/personal-server-registration.ts b/packages/vana-sdk/src/account/personal-server-registration.ts new file mode 100644 index 00000000..751b7ef4 --- /dev/null +++ b/packages/vana-sdk/src/account/personal-server-registration.ts @@ -0,0 +1,226 @@ +/** + * Optional first-party Account integration for Personal Server registration. + * + * The protocol helper lives in `protocol/personal-server-registration`. + * This module is only for callers that want to use an Account deployment's + * constrained silent-sign endpoint. + * + * @category Account + */ + +import { isAddress, type Address, type Hex } from "viem"; +import { + buildPersonalServerRegistrationTypedData, + PERSONAL_SERVER_REGISTRATION_INTENT, + type BuildPersonalServerRegistrationTypedDataInput, + type PersonalServerRegistrationSignature, + type PersonalServerRegistrationSigner, + type PersonalServerRegistrationTypedData, +} from "../protocol/personal-server-registration"; + +export type AccountPersonalServerRegistrationStatus = + | "signed" + | "confirmation_required" + | "fallback_required"; + +export interface AccountPersonalServerRegistrationRequest extends Omit< + BuildPersonalServerRegistrationTypedDataInput, + "ownerAddress" +> {} + +export interface AccountPersonalServerRegistrationConfig { + /** + * Origin for the Account deployment to call, e.g. an app-dev Account origin. + * No production origin is assumed by the SDK. + */ + accountOrigin: string; + /** + * Path for Account's constrained PS registration silent-sign endpoint. + */ + endpointPath?: string; + /** + * Optional fetch implementation for tests and non-default runtimes. + */ + fetchImpl?: typeof fetch; + /** + * Optional signer used when Account says user confirmation is required and + * returns typed data for the caller to sign interactively. + */ + fallbackSigner?: PersonalServerRegistrationSigner; +} + +export interface AccountSignedPersonalServerRegistration { + status: "signed"; + result: PersonalServerRegistrationSignature; +} + +export interface AccountConfirmationRequiredPersonalServerRegistration { + status: "confirmation_required"; + typedData: PersonalServerRegistrationTypedData; + signerAddress?: Address; +} + +export interface AccountFallbackSignedPersonalServerRegistration { + status: "fallback_signed"; + accountStatus: "confirmation_required"; + result: PersonalServerRegistrationSignature; +} + +export type AccountPersonalServerRegistrationResult = + | AccountSignedPersonalServerRegistration + | AccountConfirmationRequiredPersonalServerRegistration + | AccountFallbackSignedPersonalServerRegistration; + +interface AccountSilentSignResponse { + status: AccountPersonalServerRegistrationStatus; + signature?: Hex; + signerAddress?: Address; + signer?: { address?: Address }; + typedData?: PersonalServerRegistrationTypedData; + typed_data?: PersonalServerRegistrationTypedData; + error?: string; +} + +const DEFAULT_ACCOUNT_PS_REGISTRATION_PATH = + "/api/v1/intents/personal-server-registration/sign"; + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ""); +} + +function assertAddress(value: Address, name: string): void { + if (!isAddress(value)) { + throw new Error(`${name} must be a valid EVM address`); + } +} + +async function parseAccountResponse( + response: Response, +): Promise { + const body = (await response.json()) as AccountSilentSignResponse; + + if (!response.ok) { + throw new Error( + body.error ?? + `Account PS registration signing failed: ${response.status}`, + ); + } + + return body; +} + +function normalizeAccountResponse( + response: AccountSilentSignResponse, +): AccountSilentSignResponse { + return { + ...response, + status: + response.status === "fallback_required" + ? "confirmation_required" + : response.status, + signerAddress: response.signerAddress ?? response.signer?.address, + typedData: response.typedData ?? response.typed_data, + }; +} + +function buildSignedResult( + response: Required< + Pick + > & + Pick, + request: AccountPersonalServerRegistrationRequest, +): PersonalServerRegistrationSignature { + assertAddress(response.signerAddress, "signerAddress"); + + return { + signature: response.signature, + signerAddress: response.signerAddress, + typedData: + response.typedData ?? + buildPersonalServerRegistrationTypedData({ + ownerAddress: response.signerAddress, + ...request, + }), + intent: PERSONAL_SERVER_REGISTRATION_INTENT, + }; +} + +export async function signPersonalServerRegistrationWithAccount( + config: AccountPersonalServerRegistrationConfig, + request: AccountPersonalServerRegistrationRequest, +): Promise { + assertAddress(request.serverAddress, "serverAddress"); + + const fetchImpl = config.fetchImpl ?? globalThis.fetch.bind(globalThis); + const endpoint = new URL( + config.endpointPath ?? DEFAULT_ACCOUNT_PS_REGISTRATION_PATH, + `${trimTrailingSlash(config.accountOrigin)}/`, + ); + + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + intent: PERSONAL_SERVER_REGISTRATION_INTENT, + serverAddress: request.serverAddress, + serverPublicKey: request.serverPublicKey, + serverUrl: request.serverUrl, + config: request.config, + }), + }); + const body = normalizeAccountResponse(await parseAccountResponse(response)); + + if (body.status === "signed") { + if (!body.signature || !body.signerAddress) { + throw new Error( + "Account signed response must include signature and signerAddress", + ); + } + + return { + status: "signed", + result: buildSignedResult( + { + signature: body.signature, + signerAddress: body.signerAddress, + typedData: body.typedData, + }, + request, + ), + }; + } + + if (body.status === "confirmation_required") { + if (!body.typedData) { + throw new Error( + "Account confirmation_required response must include typedData", + ); + } + + if (!config.fallbackSigner) { + return { + status: "confirmation_required", + typedData: body.typedData, + signerAddress: body.signerAddress, + }; + } + + const signature = await config.fallbackSigner.signTypedData(body.typedData); + + return { + status: "fallback_signed", + accountStatus: "confirmation_required", + result: { + signature, + signerAddress: config.fallbackSigner.address, + typedData: body.typedData, + intent: PERSONAL_SERVER_REGISTRATION_INTENT, + }, + }; + } + + throw new Error( + `Unsupported Account PS registration signing status: ${String(body.status)}`, + ); +} diff --git a/packages/vana-sdk/src/index.browser.ts b/packages/vana-sdk/src/index.browser.ts index bef21112..f8141cff 100644 --- a/packages/vana-sdk/src/index.browser.ts +++ b/packages/vana-sdk/src/index.browser.ts @@ -160,6 +160,35 @@ export { type ServerRegistrationMessage, type BuilderRegistrationMessage, } from "./protocol/eip712"; +export { + PERSONAL_SERVER_REGISTRATION_INTENT, + PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + personalServerRegistrationDomain, + createViemPersonalServerRegistrationSigner, + buildPersonalServerRegistrationTypedData, + buildPersonalServerRegistrationSignature, + registerPersonalServerSignature, + type PersonalServerRegistrationIntent, + type PersonalServerRegistrationTypedData, + type PersonalServerRegistrationSigner, + type PersonalServerRegistrationDomainInput, + type ViemPersonalServerRegistrationWalletClient, + type ViemPersonalServerRegistrationSignerSource, + type BuildPersonalServerRegistrationTypedDataInput, + type BuildPersonalServerRegistrationSignatureInput, + type PersonalServerRegistrationSignature, +} from "./protocol/personal-server-registration"; +export { + signPersonalServerRegistrationWithAccount, + type AccountPersonalServerRegistrationStatus, + type AccountPersonalServerRegistrationRequest, + type AccountPersonalServerRegistrationConfig, + type AccountSignedPersonalServerRegistration, + type AccountConfirmationRequiredPersonalServerRegistration, + type AccountFallbackSignedPersonalServerRegistration, + type AccountPersonalServerRegistrationResult, +} from "./account/personal-server-registration"; export { isDataPortabilityGatewayConfig, parseGrantRegistrationPayload, diff --git a/packages/vana-sdk/src/index.node.ts b/packages/vana-sdk/src/index.node.ts index 69fe6a5d..fbdf6d74 100644 --- a/packages/vana-sdk/src/index.node.ts +++ b/packages/vana-sdk/src/index.node.ts @@ -160,6 +160,35 @@ export { type ServerRegistrationMessage, type BuilderRegistrationMessage, } from "./protocol/eip712"; +export { + PERSONAL_SERVER_REGISTRATION_INTENT, + PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + personalServerRegistrationDomain, + createViemPersonalServerRegistrationSigner, + buildPersonalServerRegistrationTypedData, + buildPersonalServerRegistrationSignature, + registerPersonalServerSignature, + type PersonalServerRegistrationIntent, + type PersonalServerRegistrationTypedData, + type PersonalServerRegistrationSigner, + type PersonalServerRegistrationDomainInput, + type ViemPersonalServerRegistrationWalletClient, + type ViemPersonalServerRegistrationSignerSource, + type BuildPersonalServerRegistrationTypedDataInput, + type BuildPersonalServerRegistrationSignatureInput, + type PersonalServerRegistrationSignature, +} from "./protocol/personal-server-registration"; +export { + signPersonalServerRegistrationWithAccount, + type AccountPersonalServerRegistrationStatus, + type AccountPersonalServerRegistrationRequest, + type AccountPersonalServerRegistrationConfig, + type AccountSignedPersonalServerRegistration, + type AccountConfirmationRequiredPersonalServerRegistration, + type AccountFallbackSignedPersonalServerRegistration, + type AccountPersonalServerRegistrationResult, +} from "./account/personal-server-registration"; export { isDataPortabilityGatewayConfig, parseGrantRegistrationPayload, diff --git a/packages/vana-sdk/src/protocol/eip712.ts b/packages/vana-sdk/src/protocol/eip712.ts index f1c38f02..e8c6a12c 100644 --- a/packages/vana-sdk/src/protocol/eip712.ts +++ b/packages/vana-sdk/src/protocol/eip712.ts @@ -144,7 +144,7 @@ export interface GrantRevocationMessage { export interface ServerRegistrationMessage { ownerAddress: `0x${string}`; serverAddress: `0x${string}`; - publicKey: `0x${string}`; + publicKey: string; serverUrl: string; } diff --git a/packages/vana-sdk/src/protocol/personal-server-registration.test.ts b/packages/vana-sdk/src/protocol/personal-server-registration.test.ts new file mode 100644 index 00000000..04e6298a --- /dev/null +++ b/packages/vana-sdk/src/protocol/personal-server-registration.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it, vi } from "vitest"; +import { + PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + PERSONAL_SERVER_REGISTRATION_INTENT, + buildPersonalServerRegistrationSignature, + buildPersonalServerRegistrationTypedData, + createViemPersonalServerRegistrationSigner, + personalServerRegistrationDomain, + registerPersonalServerSignature, + type PersonalServerRegistrationSigner, +} from "./personal-server-registration"; + +const OWNER_ADDRESS = "0x1111111111111111111111111111111111111111"; +const SERVER_ADDRESS = "0x2222222222222222222222222222222222222222"; +const SERVER_PUBLIC_KEY = "did:key:z6MkiPersonalServerPublicKey"; +const SERVER_URL = "https://ps.example.com"; +const SIGNATURE = `0x${"aa".repeat(65)}` as const; + +describe("Personal Server registration", () => { + it("builds the canonical ServerRegistration typed data shape", () => { + expect( + buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }), + ).toEqual({ + domain: { + name: "Vana Data Portability", + version: "1", + chainId: PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, + verifyingContract: + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + }, + types: { + ServerRegistration: [ + { name: "ownerAddress", type: "address" }, + { name: "serverAddress", type: "address" }, + { name: "publicKey", type: "string" }, + { name: "serverUrl", type: "string" }, + ], + }, + primaryType: "ServerRegistration", + message: { + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + publicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + }); + }); + + it("builds the default domain without requiring unrelated contract config", () => { + expect(personalServerRegistrationDomain()).toEqual({ + name: "Vana Data Portability", + version: "1", + chainId: PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, + verifyingContract: + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + }); + }); + + it("accepts explicit chain and verifying contract overrides", () => { + const verifyingContract = "0x3333333333333333333333333333333333333333"; + + expect( + buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + chainId: 14800, + verifyingContract, + }).domain, + ).toEqual({ + name: "Vana Data Portability", + version: "1", + chainId: 14800, + verifyingContract, + }); + }); + + it("still accepts an explicit gateway config", () => { + const verifyingContract = "0x3333333333333333333333333333333333333333"; + + expect( + buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + config: { + chainId: 14800, + contracts: { + dataRegistry: "0x4444444444444444444444444444444444444444", + dataPortabilityPermissions: + "0x5555555555555555555555555555555555555555", + dataPortabilityServer: verifyingContract, + dataPortabilityGrantees: + "0x6666666666666666666666666666666666666666", + }, + }, + }).domain, + ).toEqual({ + name: "Vana Data Portability", + version: "1", + chainId: 14800, + verifyingContract, + }); + }); + + it("uses the signer address as ownerAddress and signs the typed data", async () => { + const signer: PersonalServerRegistrationSigner = { + address: OWNER_ADDRESS, + signTypedData: vi.fn().mockResolvedValue(SIGNATURE), + }; + + const result = await buildPersonalServerRegistrationSignature({ + signer, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + + expect(signer.signTypedData).toHaveBeenCalledOnce(); + expect(signer.signTypedData).toHaveBeenCalledWith(result.typedData); + expect(result).toMatchObject({ + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + intent: PERSONAL_SERVER_REGISTRATION_INTENT, + }); + expect(result.typedData.message.ownerAddress).toBe(OWNER_ADDRESS); + }); + + it("exports the registerPersonalServerSignature alias", async () => { + const signer: PersonalServerRegistrationSigner = { + address: OWNER_ADDRESS, + signTypedData: () => SIGNATURE, + }; + + await expect( + registerPersonalServerSignature({ + signer, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }), + ).resolves.toMatchObject({ + signature: SIGNATURE, + intent: PERSONAL_SERVER_REGISTRATION_INTENT, + }); + }); + + it("adapts a viem local account-style signer", async () => { + const viemAccount = { + address: OWNER_ADDRESS, + signTypedData: vi.fn().mockResolvedValue(SIGNATURE), + }; + const signer = createViemPersonalServerRegistrationSigner(viemAccount); + + const result = await buildPersonalServerRegistrationSignature({ + signer, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + + expect(viemAccount.signTypedData).toHaveBeenCalledWith(result.typedData); + expect(result.signerAddress).toBe(OWNER_ADDRESS); + }); + + it("adapts a viem wallet client-style signer with an account", async () => { + const walletClient = { + signTypedData: vi.fn().mockResolvedValue(SIGNATURE), + }; + const signer = createViemPersonalServerRegistrationSigner(walletClient, { + account: OWNER_ADDRESS, + }); + + const result = await buildPersonalServerRegistrationSignature({ + signer, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + + expect(walletClient.signTypedData).toHaveBeenCalledWith({ + ...result.typedData, + account: OWNER_ADDRESS, + }); + }); + + it("rejects invalid address inputs", () => { + expect(() => + buildPersonalServerRegistrationTypedData({ + ownerAddress: "0xnot-an-address", + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }), + ).toThrow("ownerAddress must be a valid EVM address"); + + expect(() => + buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: "0xnot-an-address", + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }), + ).toThrow("serverAddress must be a valid EVM address"); + + expect(() => + buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + verifyingContract: "0xnot-an-address", + }), + ).toThrow("verifyingContract must be a valid EVM address"); + }); +}); diff --git a/packages/vana-sdk/src/protocol/personal-server-registration.ts b/packages/vana-sdk/src/protocol/personal-server-registration.ts new file mode 100644 index 00000000..eb1c36d6 --- /dev/null +++ b/packages/vana-sdk/src/protocol/personal-server-registration.ts @@ -0,0 +1,207 @@ +/** + * Personal Server registration typed-data and signing helpers. + * + * These helpers are protocol-owned and runtime-neutral. Apps can sign with + * viem local accounts, wallet clients, Account products, or any equivalent + * signer by adapting to {@link PersonalServerRegistrationSigner}. + * + * @category Protocol + */ + +import { + isAddress, + type Account, + type Address, + type Hex, + type TypedDataDomain, + type TypedDataDefinition, +} from "viem"; +import { + SERVER_REGISTRATION_TYPES, + serverRegistrationDomain, + type DataPortabilityGatewayConfig, + type ServerRegistrationMessage, +} from "./eip712"; + +export const PERSONAL_SERVER_REGISTRATION_INTENT = + "personal_server.server_registration.v1" as const; + +export const PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID = 1480; +export const PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT = + "0x1483B1F634DBA75AeaE60da7f01A679aabd5ee2c" as const; + +export type PersonalServerRegistrationIntent = + typeof PERSONAL_SERVER_REGISTRATION_INTENT; + +export type PersonalServerRegistrationTypedData = TypedDataDefinition< + typeof SERVER_REGISTRATION_TYPES, + "ServerRegistration" +> & { + message: ServerRegistrationMessage; +}; + +export interface PersonalServerRegistrationSigner { + address: Address; + signTypedData( + typedData: PersonalServerRegistrationTypedData, + ): Promise | Hex; +} + +export interface ViemPersonalServerRegistrationWalletClient { + account?: Account | Address | null; + signTypedData( + typedData: PersonalServerRegistrationTypedData & { + account?: Account | Address; + }, + ): Promise; +} + +export type ViemPersonalServerRegistrationSignerSource = + | PersonalServerRegistrationSigner + | ViemPersonalServerRegistrationWalletClient; + +export interface BuildPersonalServerRegistrationTypedDataInput { + ownerAddress: Address; + serverAddress: Address; + serverPublicKey: string; + serverUrl: string; + config?: DataPortabilityGatewayConfig; + chainId?: number; + verifyingContract?: Address; +} + +export interface BuildPersonalServerRegistrationSignatureInput { + signer: PersonalServerRegistrationSigner; + serverAddress: Address; + serverPublicKey: string; + serverUrl: string; + config?: DataPortabilityGatewayConfig; + chainId?: number; + verifyingContract?: Address; +} + +export interface PersonalServerRegistrationSignature { + signature: Hex; + signerAddress: Address; + typedData: PersonalServerRegistrationTypedData; + intent: PersonalServerRegistrationIntent; +} + +export interface PersonalServerRegistrationDomainInput { + config?: DataPortabilityGatewayConfig; + chainId?: number; + verifyingContract?: Address; +} + +function assertAddress(value: Address, name: string): void { + if (!isAddress(value)) { + throw new Error(`${name} must be a valid EVM address`); + } +} + +function getAccountAddress( + account: Account | Address | null | undefined, +): Address | undefined { + if (!account) { + return undefined; + } + + return typeof account === "string" ? account : account.address; +} + +function isPersonalServerRegistrationSigner( + source: ViemPersonalServerRegistrationSignerSource, +): source is PersonalServerRegistrationSigner { + return "address" in source && typeof source.signTypedData === "function"; +} + +export function createViemPersonalServerRegistrationSigner( + source: ViemPersonalServerRegistrationSignerSource, + options: { account?: Account | Address } = {}, +): PersonalServerRegistrationSigner { + if (isPersonalServerRegistrationSigner(source)) { + return source; + } + + const accountAddress = + getAccountAddress(options.account) ?? getAccountAddress(source.account); + + if (accountAddress) { + return { + address: accountAddress, + signTypedData: (typedData) => + source.signTypedData({ + ...typedData, + account: options.account ?? source.account ?? accountAddress, + }), + }; + } + + throw new Error( + "Viem wallet client requires an account option or account property", + ); +} + +export function personalServerRegistrationDomain( + input: PersonalServerRegistrationDomainInput = {}, +): TypedDataDomain { + if (input.config) { + return serverRegistrationDomain(input.config); + } + + const verifyingContract = + input.verifyingContract ?? + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT; + assertAddress(verifyingContract, "verifyingContract"); + + return { + name: "Vana Data Portability", + version: "1", + chainId: input.chainId ?? PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, + verifyingContract, + }; +} + +export function buildPersonalServerRegistrationTypedData( + input: BuildPersonalServerRegistrationTypedDataInput, +): PersonalServerRegistrationTypedData { + assertAddress(input.ownerAddress, "ownerAddress"); + assertAddress(input.serverAddress, "serverAddress"); + + return { + domain: personalServerRegistrationDomain(input), + types: SERVER_REGISTRATION_TYPES, + primaryType: "ServerRegistration", + message: { + ownerAddress: input.ownerAddress, + serverAddress: input.serverAddress, + publicKey: input.serverPublicKey, + serverUrl: input.serverUrl, + }, + }; +} + +export async function buildPersonalServerRegistrationSignature( + input: BuildPersonalServerRegistrationSignatureInput, +): Promise { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: input.signer.address, + serverAddress: input.serverAddress, + serverPublicKey: input.serverPublicKey, + serverUrl: input.serverUrl, + config: input.config, + chainId: input.chainId, + verifyingContract: input.verifyingContract, + }); + const signature = await input.signer.signTypedData(typedData); + + return { + signature, + signerAddress: input.signer.address, + typedData, + intent: PERSONAL_SERVER_REGISTRATION_INTENT, + }; +} + +export const registerPersonalServerSignature = + buildPersonalServerRegistrationSignature; From 752efd16ab6aa619bd1333573906c383cda50fd4 Mon Sep 17 00:00:00 2001 From: Tim Nunamak Date: Wed, 13 May 2026 18:03:59 -0500 Subject: [PATCH 2/8] fix(account): forward registration domain overrides --- .../personal-server-registration.test.ts | 37 +++++++++++++++++++ .../account/personal-server-registration.ts | 2 + 2 files changed, 39 insertions(+) diff --git a/packages/vana-sdk/src/account/personal-server-registration.test.ts b/packages/vana-sdk/src/account/personal-server-registration.test.ts index 91b703fe..d3a66b66 100644 --- a/packages/vana-sdk/src/account/personal-server-registration.test.ts +++ b/packages/vana-sdk/src/account/personal-server-registration.test.ts @@ -117,6 +117,43 @@ describe("Account Personal Server registration integration", () => { }); }); + it("passes optional domain overrides through to Account", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "signed", + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + }), + ); + + await signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + chainId: 31337, + verifyingContract: + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + }, + ); + + expect(fetchImpl).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + body: JSON.stringify({ + intent: PERSONAL_SERVER_REGISTRATION_INTENT, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + chainId: 31337, + verifyingContract: + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + }), + }), + ); + }); + it("normalizes the current experimental silent-sign response shape", async () => { const typedData = buildPersonalServerRegistrationTypedData({ ownerAddress: OWNER_ADDRESS, diff --git a/packages/vana-sdk/src/account/personal-server-registration.ts b/packages/vana-sdk/src/account/personal-server-registration.ts index 751b7ef4..adc54702 100644 --- a/packages/vana-sdk/src/account/personal-server-registration.ts +++ b/packages/vana-sdk/src/account/personal-server-registration.ts @@ -167,6 +167,8 @@ export async function signPersonalServerRegistrationWithAccount( serverPublicKey: request.serverPublicKey, serverUrl: request.serverUrl, config: request.config, + chainId: request.chainId, + verifyingContract: request.verifyingContract, }), }); const body = normalizeAccountResponse(await parseAccountResponse(response)); From 8b2bcb534e076afef5b61a98e9b7ec432d89fab5 Mon Sep 17 00:00:00 2001 From: Tim Nunamak Date: Wed, 13 May 2026 18:10:33 -0500 Subject: [PATCH 3/8] fix(account): preserve signing error details --- .../personal-server-registration.test.ts | 30 +++++++ .../account/personal-server-registration.ts | 87 +++++++++++++++++-- packages/vana-sdk/src/index.browser.ts | 1 + packages/vana-sdk/src/index.node.ts | 1 + 4 files changed, 112 insertions(+), 7 deletions(-) diff --git a/packages/vana-sdk/src/account/personal-server-registration.test.ts b/packages/vana-sdk/src/account/personal-server-registration.test.ts index d3a66b66..cfa1cb39 100644 --- a/packages/vana-sdk/src/account/personal-server-registration.test.ts +++ b/packages/vana-sdk/src/account/personal-server-registration.test.ts @@ -5,6 +5,7 @@ import { buildPersonalServerRegistrationTypedData, type PersonalServerRegistrationSigner, } from "../protocol/personal-server-registration"; +import type { AccountPersonalServerRegistrationError } from "./personal-server-registration"; import { signPersonalServerRegistrationWithAccount } from "./personal-server-registration"; const ACCOUNT_ORIGIN = "https://account.app-dev.example"; @@ -268,4 +269,33 @@ describe("Account Personal Server registration integration", () => { }, }); }); + + it("preserves structured Account error details", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue( + jsonResponse( + { error: { code: "account_session_required" } }, + { status: 401 }, + ), + ); + + await expect( + signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ), + ).rejects.toMatchObject({ + name: "AccountPersonalServerRegistrationError", + status: 401, + code: "account_session_required", + message: + "Account PS registration signing failed: account_session_required", + details: { error: { code: "account_session_required" } }, + } satisfies Partial); + }); }); diff --git a/packages/vana-sdk/src/account/personal-server-registration.ts b/packages/vana-sdk/src/account/personal-server-registration.ts index adc54702..7e988fdc 100644 --- a/packages/vana-sdk/src/account/personal-server-registration.ts +++ b/packages/vana-sdk/src/account/personal-server-registration.ts @@ -71,6 +71,25 @@ export type AccountPersonalServerRegistrationResult = | AccountConfirmationRequiredPersonalServerRegistration | AccountFallbackSignedPersonalServerRegistration; +export class AccountPersonalServerRegistrationError extends Error { + status: number; + code?: string; + details?: unknown; + + constructor(input: { + status: number; + message: string; + code?: string; + details?: unknown; + }) { + super(input.message); + this.name = "AccountPersonalServerRegistrationError"; + this.status = input.status; + this.code = input.code; + this.details = input.details; + } +} + interface AccountSilentSignResponse { status: AccountPersonalServerRegistrationStatus; signature?: Hex; @@ -78,7 +97,7 @@ interface AccountSilentSignResponse { signer?: { address?: Address }; typedData?: PersonalServerRegistrationTypedData; typed_data?: PersonalServerRegistrationTypedData; - error?: string; + error?: unknown; } const DEFAULT_ACCOUNT_PS_REGISTRATION_PATH = @@ -97,16 +116,70 @@ function assertAddress(value: Address, name: string): void { async function parseAccountResponse( response: Response, ): Promise { - const body = (await response.json()) as AccountSilentSignResponse; + const body = (await response.json().catch(() => undefined)) as unknown; if (!response.ok) { - throw new Error( - body.error ?? - `Account PS registration signing failed: ${response.status}`, - ); + throw new AccountPersonalServerRegistrationError({ + status: response.status, + code: accountErrorCode(body), + message: accountErrorMessage(response.status, body), + details: body, + }); + } + + return body as AccountSilentSignResponse; +} + +function accountErrorMessage(status: number, body: unknown): string { + const nestedMessage = nestedAccountErrorField(body, "message"); + if (nestedMessage) { + return nestedMessage; + } + + if (isRecord(body) && typeof body.message === "string") { + return body.message; + } + + const code = accountErrorCode(body); + if (code) { + return `Account PS registration signing failed: ${code}`; + } + + return `Account PS registration signing failed: ${status}`; +} + +function accountErrorCode(body: unknown): string | undefined { + const nestedCode = nestedAccountErrorField(body, "code"); + if (nestedCode) { + return nestedCode; + } + + if (isRecord(body)) { + if (typeof body.code === "string") { + return body.code; + } + if (typeof body.error === "string") { + return body.error; + } } - return body; + return undefined; +} + +function nestedAccountErrorField( + body: unknown, + field: "code" | "message", +): string | undefined { + if (!isRecord(body) || !isRecord(body.error)) { + return undefined; + } + + const value = body.error[field]; + return typeof value === "string" ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; } function normalizeAccountResponse( diff --git a/packages/vana-sdk/src/index.browser.ts b/packages/vana-sdk/src/index.browser.ts index f8141cff..fa9c2731 100644 --- a/packages/vana-sdk/src/index.browser.ts +++ b/packages/vana-sdk/src/index.browser.ts @@ -180,6 +180,7 @@ export { type PersonalServerRegistrationSignature, } from "./protocol/personal-server-registration"; export { + AccountPersonalServerRegistrationError, signPersonalServerRegistrationWithAccount, type AccountPersonalServerRegistrationStatus, type AccountPersonalServerRegistrationRequest, diff --git a/packages/vana-sdk/src/index.node.ts b/packages/vana-sdk/src/index.node.ts index fbdf6d74..16df4653 100644 --- a/packages/vana-sdk/src/index.node.ts +++ b/packages/vana-sdk/src/index.node.ts @@ -180,6 +180,7 @@ export { type PersonalServerRegistrationSignature, } from "./protocol/personal-server-registration"; export { + AccountPersonalServerRegistrationError, signPersonalServerRegistrationWithAccount, type AccountPersonalServerRegistrationStatus, type AccountPersonalServerRegistrationRequest, From 3bdce032d214fa02758458058662493531a6cc0c Mon Sep 17 00:00:00 2001 From: Tim Nunamak Date: Wed, 13 May 2026 20:04:19 -0500 Subject: [PATCH 4/8] feat(account): add PS Lite owner binding signing --- ...personal-server-lite-owner-binding.test.ts | 63 +++++++++ .../personal-server-lite-owner-binding.ts | 99 +++++++++++++ packages/vana-sdk/src/index.browser.ts | 22 +++ packages/vana-sdk/src/index.node.ts | 22 +++ ...personal-server-lite-owner-binding.test.ts | 69 +++++++++ .../personal-server-lite-owner-binding.ts | 133 ++++++++++++++++++ 6 files changed, 408 insertions(+) create mode 100644 packages/vana-sdk/src/account/personal-server-lite-owner-binding.test.ts create mode 100644 packages/vana-sdk/src/account/personal-server-lite-owner-binding.ts create mode 100644 packages/vana-sdk/src/protocol/personal-server-lite-owner-binding.test.ts create mode 100644 packages/vana-sdk/src/protocol/personal-server-lite-owner-binding.ts diff --git a/packages/vana-sdk/src/account/personal-server-lite-owner-binding.test.ts b/packages/vana-sdk/src/account/personal-server-lite-owner-binding.test.ts new file mode 100644 index 00000000..529b3861 --- /dev/null +++ b/packages/vana-sdk/src/account/personal-server-lite-owner-binding.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Address } from "viem"; +import { signPersonalServerLiteOwnerBindingWithAccountClient } from "./personal-server-lite-owner-binding"; +import type { AccountPersonalServerLiteOwnerBindingError } from "./personal-server-lite-owner-binding"; + +const OWNER_ADDRESS = "0x2ab394e4be7c43ac360d226a31e1c90bc01aafa1" as Address; +const LOWER_OWNER_ADDRESS = "0x2ab394e4be7c43ac360d226a31e1c90bc01aafa1"; +const SIGNATURE = `0x${"cc".repeat(65)}` as const; + +describe("Account PS Lite owner-binding integration", () => { + it("gets the Account wallet and signs the PS Lite owner-binding message", async () => { + const client = { + getAddress: vi.fn().mockResolvedValue(OWNER_ADDRESS), + signMessage: vi.fn().mockResolvedValue(SIGNATURE), + }; + + const result = await signPersonalServerLiteOwnerBindingWithAccountClient({ + client, + }); + + const message = `vana.account.v1:ps-lite-owner:${LOWER_OWNER_ADDRESS}`; + expect(client.signMessage).toHaveBeenCalledWith({ message }); + expect(result).toEqual({ + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + message, + purpose: "ps-lite-owner", + }); + }); + + it("uses a typed Account error when Account has no wallet", async () => { + const client = { + getAddress: vi.fn().mockResolvedValue(null), + signMessage: vi.fn(), + }; + + await expect( + signPersonalServerLiteOwnerBindingWithAccountClient({ client }), + ).rejects.toMatchObject({ + name: "AccountPersonalServerLiteOwnerBindingError", + code: "account_address_required", + } satisfies Partial); + }); + + it("preserves wallet rejection codes", async () => { + const rejection = Object.assign(new Error("User rejected request"), { + code: 4001, + }); + const client = { + getAddress: vi.fn().mockResolvedValue(OWNER_ADDRESS), + signMessage: vi.fn().mockRejectedValue(rejection), + }; + + await expect( + signPersonalServerLiteOwnerBindingWithAccountClient({ client }), + ).rejects.toMatchObject({ + name: "AccountPersonalServerLiteOwnerBindingError", + code: 4001, + message: "User rejected request", + details: rejection, + } satisfies Partial); + }); +}); diff --git a/packages/vana-sdk/src/account/personal-server-lite-owner-binding.ts b/packages/vana-sdk/src/account/personal-server-lite-owner-binding.ts new file mode 100644 index 00000000..a6bbf544 --- /dev/null +++ b/packages/vana-sdk/src/account/personal-server-lite-owner-binding.ts @@ -0,0 +1,99 @@ +/** + * Optional first-party Account integration for PS Lite owner binding. + * + * The protocol helper lives in `protocol/personal-server-lite-owner-binding`. + * This module adapts any Account-style client exposing `getAddress` and + * `signMessage` to the SDK owner-binding signature shape. + * + * @category Account + */ + +import type { Address, Hex } from "viem"; +import { + buildPersonalServerLiteOwnerBindingMessage, + PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE, + type PersonalServerLiteOwnerBindingSignature, +} from "../protocol/personal-server-lite-owner-binding"; + +export interface AccountPersonalServerLiteOwnerBindingClient { + getAddress(): Promise
| Address | null; + signMessage(input: { + message: ReturnType; + }): Promise | Hex; +} + +export interface SignPersonalServerLiteOwnerBindingWithAccountClientConfig { + client: AccountPersonalServerLiteOwnerBindingClient; +} + +export class AccountPersonalServerLiteOwnerBindingError extends Error { + code?: number | string; + details?: unknown; + + constructor(input: { + message: string; + code?: number | string; + details?: unknown; + }) { + super(input.message); + this.name = "AccountPersonalServerLiteOwnerBindingError"; + this.code = input.code; + this.details = input.details; + } +} + +export async function signPersonalServerLiteOwnerBindingWithAccountClient( + config: SignPersonalServerLiteOwnerBindingWithAccountClientConfig, +): Promise { + let address: Address | null; + try { + address = await config.client.getAddress(); + } catch (error) { + throw accountOwnerBindingError(error); + } + + if (!address) { + throw new AccountPersonalServerLiteOwnerBindingError({ + message: "Account did not return a wallet address", + code: "account_address_required", + }); + } + + const message = buildPersonalServerLiteOwnerBindingMessage(address); + let signature: Hex; + try { + signature = await config.client.signMessage({ message }); + } catch (error) { + throw accountOwnerBindingError(error); + } + + return { + signature, + signerAddress: address, + message, + purpose: PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE, + }; +} + +function accountOwnerBindingError( + error: unknown, +): AccountPersonalServerLiteOwnerBindingError { + if (error instanceof AccountPersonalServerLiteOwnerBindingError) { + return error; + } + + const rpcError = error as + | { code?: number | string; message?: string } + | undefined; + const code = rpcError?.code; + const message = + typeof rpcError?.message === "string" && rpcError.message.length > 0 + ? rpcError.message + : "Account PS Lite owner-binding signature failed"; + + return new AccountPersonalServerLiteOwnerBindingError({ + message, + code, + details: error, + }); +} diff --git a/packages/vana-sdk/src/index.browser.ts b/packages/vana-sdk/src/index.browser.ts index fa9c2731..bae0a14f 100644 --- a/packages/vana-sdk/src/index.browser.ts +++ b/packages/vana-sdk/src/index.browser.ts @@ -179,6 +179,22 @@ export { type BuildPersonalServerRegistrationSignatureInput, type PersonalServerRegistrationSignature, } from "./protocol/personal-server-registration"; +export { + PERSONAL_SERVER_LITE_OWNER_BINDING_VERSION, + PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE, + PERSONAL_SERVER_LITE_OWNER_BINDING_PREFIX, + buildPersonalServerLiteOwnerBindingMessage, + createViemPersonalServerLiteOwnerBindingSigner, + buildPersonalServerLiteOwnerBindingSignature, + signPersonalServerLiteOwnerBinding, + type PersonalServerLiteOwnerBindingPurpose, + type PersonalServerLiteOwnerBindingMessage, + type PersonalServerLiteOwnerBindingSigner, + type ViemPersonalServerLiteOwnerBindingWalletClient, + type ViemPersonalServerLiteOwnerBindingSignerSource, + type BuildPersonalServerLiteOwnerBindingSignatureInput, + type PersonalServerLiteOwnerBindingSignature, +} from "./protocol/personal-server-lite-owner-binding"; export { AccountPersonalServerRegistrationError, signPersonalServerRegistrationWithAccount, @@ -190,6 +206,12 @@ export { type AccountFallbackSignedPersonalServerRegistration, type AccountPersonalServerRegistrationResult, } from "./account/personal-server-registration"; +export { + AccountPersonalServerLiteOwnerBindingError, + signPersonalServerLiteOwnerBindingWithAccountClient, + type AccountPersonalServerLiteOwnerBindingClient, + type SignPersonalServerLiteOwnerBindingWithAccountClientConfig, +} from "./account/personal-server-lite-owner-binding"; export { isDataPortabilityGatewayConfig, parseGrantRegistrationPayload, diff --git a/packages/vana-sdk/src/index.node.ts b/packages/vana-sdk/src/index.node.ts index 16df4653..e6c02bec 100644 --- a/packages/vana-sdk/src/index.node.ts +++ b/packages/vana-sdk/src/index.node.ts @@ -179,6 +179,22 @@ export { type BuildPersonalServerRegistrationSignatureInput, type PersonalServerRegistrationSignature, } from "./protocol/personal-server-registration"; +export { + PERSONAL_SERVER_LITE_OWNER_BINDING_VERSION, + PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE, + PERSONAL_SERVER_LITE_OWNER_BINDING_PREFIX, + buildPersonalServerLiteOwnerBindingMessage, + createViemPersonalServerLiteOwnerBindingSigner, + buildPersonalServerLiteOwnerBindingSignature, + signPersonalServerLiteOwnerBinding, + type PersonalServerLiteOwnerBindingPurpose, + type PersonalServerLiteOwnerBindingMessage, + type PersonalServerLiteOwnerBindingSigner, + type ViemPersonalServerLiteOwnerBindingWalletClient, + type ViemPersonalServerLiteOwnerBindingSignerSource, + type BuildPersonalServerLiteOwnerBindingSignatureInput, + type PersonalServerLiteOwnerBindingSignature, +} from "./protocol/personal-server-lite-owner-binding"; export { AccountPersonalServerRegistrationError, signPersonalServerRegistrationWithAccount, @@ -190,6 +206,12 @@ export { type AccountFallbackSignedPersonalServerRegistration, type AccountPersonalServerRegistrationResult, } from "./account/personal-server-registration"; +export { + AccountPersonalServerLiteOwnerBindingError, + signPersonalServerLiteOwnerBindingWithAccountClient, + type AccountPersonalServerLiteOwnerBindingClient, + type SignPersonalServerLiteOwnerBindingWithAccountClientConfig, +} from "./account/personal-server-lite-owner-binding"; export { isDataPortabilityGatewayConfig, parseGrantRegistrationPayload, diff --git a/packages/vana-sdk/src/protocol/personal-server-lite-owner-binding.test.ts b/packages/vana-sdk/src/protocol/personal-server-lite-owner-binding.test.ts new file mode 100644 index 00000000..8c19b328 --- /dev/null +++ b/packages/vana-sdk/src/protocol/personal-server-lite-owner-binding.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Address } from "viem"; +import { + buildPersonalServerLiteOwnerBindingMessage, + buildPersonalServerLiteOwnerBindingSignature, + createViemPersonalServerLiteOwnerBindingSigner, + PERSONAL_SERVER_LITE_OWNER_BINDING_PREFIX, + PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE, +} from "./personal-server-lite-owner-binding"; + +const OWNER_ADDRESS = "0x2ab394e4be7c43ac360d226a31e1c90bc01aafa1" as Address; +const LOWER_OWNER_ADDRESS = "0x2ab394e4be7c43ac360d226a31e1c90bc01aafa1"; +const SIGNATURE = `0x${"bb".repeat(65)}` as const; + +describe("PS Lite owner-binding helpers", () => { + it("builds the stable owner-binding message expected by PS Lite", () => { + expect(PERSONAL_SERVER_LITE_OWNER_BINDING_PREFIX).toBe( + "vana.account.v1:ps-lite-owner:", + ); + expect(buildPersonalServerLiteOwnerBindingMessage(OWNER_ADDRESS)).toBe( + `vana.account.v1:ps-lite-owner:${LOWER_OWNER_ADDRESS}`, + ); + }); + + it("rejects invalid owner addresses", () => { + expect(() => + buildPersonalServerLiteOwnerBindingMessage("not-an-address" as never), + ).toThrow("ownerAddress must be a valid EVM address"); + }); + + it("signs the owner-binding message with a generic signer", async () => { + const signer = { + address: OWNER_ADDRESS, + signMessage: vi.fn().mockResolvedValue(SIGNATURE), + }; + + const result = await buildPersonalServerLiteOwnerBindingSignature({ + signer, + }); + + const message = `vana.account.v1:ps-lite-owner:${LOWER_OWNER_ADDRESS}`; + expect(signer.signMessage).toHaveBeenCalledWith({ message }); + expect(result).toEqual({ + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + message, + purpose: PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE, + }); + }); + + it("adapts viem wallet clients without hiding the explicit account", async () => { + const walletClient = { + signMessage: vi.fn().mockResolvedValue(SIGNATURE), + }; + const signer = createViemPersonalServerLiteOwnerBindingSigner( + walletClient, + { account: OWNER_ADDRESS }, + ); + + const message = buildPersonalServerLiteOwnerBindingMessage(OWNER_ADDRESS); + await signer.signMessage({ message }); + + expect(signer.address).toBe(OWNER_ADDRESS); + expect(walletClient.signMessage).toHaveBeenCalledWith({ + account: OWNER_ADDRESS, + message, + }); + }); +}); diff --git a/packages/vana-sdk/src/protocol/personal-server-lite-owner-binding.ts b/packages/vana-sdk/src/protocol/personal-server-lite-owner-binding.ts new file mode 100644 index 00000000..072fd779 --- /dev/null +++ b/packages/vana-sdk/src/protocol/personal-server-lite-owner-binding.ts @@ -0,0 +1,133 @@ +/** + * PS Lite owner-binding message and signing helpers. + * + * PS Lite uses this replayable personal-sign message as a wallet-owned input + * for opening the user's local encrypted runtime. This is intentionally + * separate from Personal Server registration, which is EIP-712 typed data. + * + * @category Protocol + */ + +import { + isAddress, + type Account, + type Address, + type Hex, + type SignableMessage, +} from "viem"; + +export const PERSONAL_SERVER_LITE_OWNER_BINDING_VERSION = "vana.account.v1"; +export const PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE = "ps-lite-owner"; +export const PERSONAL_SERVER_LITE_OWNER_BINDING_PREFIX = + `${PERSONAL_SERVER_LITE_OWNER_BINDING_VERSION}:${PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE}:` as const; + +export type PersonalServerLiteOwnerBindingPurpose = + typeof PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE; + +export type PersonalServerLiteOwnerBindingMessage = + `${typeof PERSONAL_SERVER_LITE_OWNER_BINDING_PREFIX}${Lowercase
}`; + +export interface PersonalServerLiteOwnerBindingSigner { + address: Address; + signMessage(input: { + message: PersonalServerLiteOwnerBindingMessage; + }): Promise | Hex; +} + +export interface ViemPersonalServerLiteOwnerBindingWalletClient { + account?: Account | Address | null; + signMessage(input: { + account?: Account | Address; + message: SignableMessage; + }): Promise; +} + +export type ViemPersonalServerLiteOwnerBindingSignerSource = + | PersonalServerLiteOwnerBindingSigner + | ViemPersonalServerLiteOwnerBindingWalletClient; + +export interface BuildPersonalServerLiteOwnerBindingSignatureInput { + signer: PersonalServerLiteOwnerBindingSigner; +} + +export interface PersonalServerLiteOwnerBindingSignature { + signature: Hex; + signerAddress: Address; + message: PersonalServerLiteOwnerBindingMessage; + purpose: PersonalServerLiteOwnerBindingPurpose; +} + +function assertAddress(value: Address, name: string): void { + if (!isAddress(value)) { + throw new Error(`${name} must be a valid EVM address`); + } +} + +function getAccountAddress( + account: Account | Address | null | undefined, +): Address | undefined { + if (!account) { + return undefined; + } + + return typeof account === "string" ? account : account.address; +} + +function isPersonalServerLiteOwnerBindingSigner( + source: ViemPersonalServerLiteOwnerBindingSignerSource, +): source is PersonalServerLiteOwnerBindingSigner { + return "address" in source && typeof source.signMessage === "function"; +} + +export function buildPersonalServerLiteOwnerBindingMessage( + ownerAddress: Address, +): PersonalServerLiteOwnerBindingMessage { + assertAddress(ownerAddress, "ownerAddress"); + return `${PERSONAL_SERVER_LITE_OWNER_BINDING_PREFIX}${ownerAddress.toLowerCase()}` as PersonalServerLiteOwnerBindingMessage; +} + +export function createViemPersonalServerLiteOwnerBindingSigner( + source: ViemPersonalServerLiteOwnerBindingSignerSource, + options: { account?: Account | Address } = {}, +): PersonalServerLiteOwnerBindingSigner { + if (isPersonalServerLiteOwnerBindingSigner(source)) { + return source; + } + + const accountAddress = + getAccountAddress(options.account) ?? getAccountAddress(source.account); + + if (accountAddress) { + return { + address: accountAddress, + signMessage: ({ message }) => + source.signMessage({ + account: options.account ?? source.account ?? accountAddress, + message, + }), + }; + } + + throw new Error( + "Viem wallet client requires an account option or account property", + ); +} + +export async function buildPersonalServerLiteOwnerBindingSignature( + input: BuildPersonalServerLiteOwnerBindingSignatureInput, +): Promise { + const message = buildPersonalServerLiteOwnerBindingMessage( + input.signer.address, + ); + const signature = await input.signer.signMessage({ message }); + + return { + signature, + signerAddress: input.signer.address, + message, + purpose: PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE, + }; +} + +export const signPersonalServerLiteOwnerBinding = + buildPersonalServerLiteOwnerBindingSignature; From efdd37ac9963c7706d24cdb2b22e0428b868b723 Mon Sep 17 00:00:00 2001 From: Tim Nunamak Date: Thu, 14 May 2026 10:23:00 -0500 Subject: [PATCH 5/8] fix sdk ps registration ownership boundaries --- .github/workflows/prerelease.yml | 72 ++++++++++++++++++- .../personal-server-registration.test.ts | 16 +++-- .../account/personal-server-registration.ts | 26 +++++-- packages/vana-sdk/src/index.browser.ts | 5 +- packages/vana-sdk/src/index.node.ts | 5 +- .../personal-server-registration.test.ts | 4 +- .../protocol/personal-server-registration.ts | 8 --- 7 files changed, 105 insertions(+), 31 deletions(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 39a982fc..5fadbba2 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -1,6 +1,12 @@ name: Vana SDK Pre-release on: + workflow_dispatch: + inputs: + pr_number: + description: "PR number to publish, e.g. 147" + required: true + type: number push: branches: - feature/data-portability-sdk-v1 @@ -12,7 +18,71 @@ on: - remove-subgraph jobs: + publish-pr-prerelease: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Resolve PR head + id: pr + uses: actions/github-script@v7 + with: + script: | + const prNumber = Number(core.getInput('pr_number')); + const { owner, repo } = context.repo; + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: prNumber, + }); + + if (pull.head.repo.full_name !== `${owner}/${repo}`) { + core.setFailed( + `Refusing to publish prerelease for fork PR ${prNumber}: ${pull.head.repo.full_name}`, + ); + return; + } + + core.setOutput('head_sha', pull.head.sha); + core.setOutput('dist_tag', `pr-${prNumber}`); + core.setOutput('version_suffix', `pr.${prNumber}.${pull.head.sha.slice(0, 7)}`); + + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ steps.pr.outputs.head_sha }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Set PR pre-release version + run: | + cd packages/vana-sdk + CURRENT_VERSION=$(node -p "require('./package.json').version") + npm version "${CURRENT_VERSION}-${VERSION_SUFFIX}" --no-git-tag-version --allow-same-version + env: + VERSION_SUFFIX: ${{ steps.pr.outputs.version_suffix }} + + - name: Build and publish PR pre-release + run: | + cd packages/vana-sdk + NODE_OPTIONS="--max-old-space-size=4096" npm run build + npm publish --tag "${DIST_TAG}" --access public + env: + DIST_TAG: ${{ steps.pr.outputs.dist_tag }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + publish-prerelease: + if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: read @@ -39,8 +109,6 @@ jobs: - name: Verify npm token run: | - echo "Token length: ${#NODE_AUTH_TOKEN}" - echo "Token prefix: ${NODE_AUTH_TOKEN:0:4}..." npm whoami --registry https://registry.npmjs.org/ || echo "npm whoami failed - token may be invalid" npm view @opendatalabs/vana-sdk version --registry https://registry.npmjs.org/ || echo "Cannot access package" cat ~/.npmrc 2>/dev/null | sed 's/=.*/=/' || echo "No .npmrc found" diff --git a/packages/vana-sdk/src/account/personal-server-registration.test.ts b/packages/vana-sdk/src/account/personal-server-registration.test.ts index cfa1cb39..eac6348b 100644 --- a/packages/vana-sdk/src/account/personal-server-registration.test.ts +++ b/packages/vana-sdk/src/account/personal-server-registration.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it, vi } from "vitest"; import { PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, - PERSONAL_SERVER_REGISTRATION_INTENT, buildPersonalServerRegistrationTypedData, type PersonalServerRegistrationSigner, } from "../protocol/personal-server-registration"; import type { AccountPersonalServerRegistrationError } from "./personal-server-registration"; -import { signPersonalServerRegistrationWithAccount } from "./personal-server-registration"; +import { + ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, + signPersonalServerRegistrationWithAccount, +} from "./personal-server-registration"; const ACCOUNT_ORIGIN = "https://account.app-dev.example"; const OWNER_ADDRESS = "0x1111111111111111111111111111111111111111"; @@ -52,7 +54,7 @@ describe("Account Personal Server registration integration", () => { credentials: "include", headers: { "content-type": "application/json" }, body: JSON.stringify({ - intent: PERSONAL_SERVER_REGISTRATION_INTENT, + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, serverAddress: SERVER_ADDRESS, serverPublicKey: SERVER_PUBLIC_KEY, serverUrl: SERVER_URL, @@ -70,7 +72,7 @@ describe("Account Personal Server registration integration", () => { serverPublicKey: SERVER_PUBLIC_KEY, serverUrl: SERVER_URL, }), - intent: PERSONAL_SERVER_REGISTRATION_INTENT, + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, }, }); }); @@ -143,7 +145,7 @@ describe("Account Personal Server registration integration", () => { expect.any(URL), expect.objectContaining({ body: JSON.stringify({ - intent: PERSONAL_SERVER_REGISTRATION_INTENT, + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, serverAddress: SERVER_ADDRESS, serverPublicKey: SERVER_PUBLIC_KEY, serverUrl: SERVER_URL, @@ -225,7 +227,7 @@ describe("Account Personal Server registration integration", () => { signature: SIGNATURE, signerAddress: OWNER_ADDRESS, typedData, - intent: PERSONAL_SERVER_REGISTRATION_INTENT, + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, }, }); }); @@ -265,7 +267,7 @@ describe("Account Personal Server registration integration", () => { signature: SIGNATURE, signerAddress: OWNER_ADDRESS, typedData, - intent: PERSONAL_SERVER_REGISTRATION_INTENT, + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, }, }); }); diff --git a/packages/vana-sdk/src/account/personal-server-registration.ts b/packages/vana-sdk/src/account/personal-server-registration.ts index 7e988fdc..0d5ebaf7 100644 --- a/packages/vana-sdk/src/account/personal-server-registration.ts +++ b/packages/vana-sdk/src/account/personal-server-registration.ts @@ -11,13 +11,18 @@ import { isAddress, type Address, type Hex } from "viem"; import { buildPersonalServerRegistrationTypedData, - PERSONAL_SERVER_REGISTRATION_INTENT, type BuildPersonalServerRegistrationTypedDataInput, type PersonalServerRegistrationSignature, type PersonalServerRegistrationSigner, type PersonalServerRegistrationTypedData, } from "../protocol/personal-server-registration"; +export const ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT = + "personal_server.server_registration.v1" as const; + +export type AccountPersonalServerRegistrationIntent = + typeof ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT; + export type AccountPersonalServerRegistrationStatus = | "signed" | "confirmation_required" @@ -49,9 +54,14 @@ export interface AccountPersonalServerRegistrationConfig { fallbackSigner?: PersonalServerRegistrationSigner; } +export type AccountPersonalServerRegistrationSignature = + PersonalServerRegistrationSignature & { + intent: AccountPersonalServerRegistrationIntent; + }; + export interface AccountSignedPersonalServerRegistration { status: "signed"; - result: PersonalServerRegistrationSignature; + result: AccountPersonalServerRegistrationSignature; } export interface AccountConfirmationRequiredPersonalServerRegistration { @@ -63,7 +73,7 @@ export interface AccountConfirmationRequiredPersonalServerRegistration { export interface AccountFallbackSignedPersonalServerRegistration { status: "fallback_signed"; accountStatus: "confirmation_required"; - result: PersonalServerRegistrationSignature; + result: AccountPersonalServerRegistrationSignature; } export type AccountPersonalServerRegistrationResult = @@ -100,6 +110,8 @@ interface AccountSilentSignResponse { error?: unknown; } +// Account-owned route policy. Protocol signing primitives deliberately do not +// define Account intent names or API paths. const DEFAULT_ACCOUNT_PS_REGISTRATION_PATH = "/api/v1/intents/personal-server-registration/sign"; @@ -202,7 +214,7 @@ function buildSignedResult( > & Pick, request: AccountPersonalServerRegistrationRequest, -): PersonalServerRegistrationSignature { +): AccountPersonalServerRegistrationSignature { assertAddress(response.signerAddress, "signerAddress"); return { @@ -214,7 +226,7 @@ function buildSignedResult( ownerAddress: response.signerAddress, ...request, }), - intent: PERSONAL_SERVER_REGISTRATION_INTENT, + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, }; } @@ -235,7 +247,7 @@ export async function signPersonalServerRegistrationWithAccount( headers: { "content-type": "application/json" }, credentials: "include", body: JSON.stringify({ - intent: PERSONAL_SERVER_REGISTRATION_INTENT, + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, serverAddress: request.serverAddress, serverPublicKey: request.serverPublicKey, serverUrl: request.serverUrl, @@ -290,7 +302,7 @@ export async function signPersonalServerRegistrationWithAccount( signature, signerAddress: config.fallbackSigner.address, typedData: body.typedData, - intent: PERSONAL_SERVER_REGISTRATION_INTENT, + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, }, }; } diff --git a/packages/vana-sdk/src/index.browser.ts b/packages/vana-sdk/src/index.browser.ts index bae0a14f..b7676e30 100644 --- a/packages/vana-sdk/src/index.browser.ts +++ b/packages/vana-sdk/src/index.browser.ts @@ -161,7 +161,6 @@ export { type BuilderRegistrationMessage, } from "./protocol/eip712"; export { - PERSONAL_SERVER_REGISTRATION_INTENT, PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, personalServerRegistrationDomain, @@ -169,7 +168,6 @@ export { buildPersonalServerRegistrationTypedData, buildPersonalServerRegistrationSignature, registerPersonalServerSignature, - type PersonalServerRegistrationIntent, type PersonalServerRegistrationTypedData, type PersonalServerRegistrationSigner, type PersonalServerRegistrationDomainInput, @@ -196,8 +194,11 @@ export { type PersonalServerLiteOwnerBindingSignature, } from "./protocol/personal-server-lite-owner-binding"; export { + ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, AccountPersonalServerRegistrationError, signPersonalServerRegistrationWithAccount, + type AccountPersonalServerRegistrationIntent, + type AccountPersonalServerRegistrationSignature, type AccountPersonalServerRegistrationStatus, type AccountPersonalServerRegistrationRequest, type AccountPersonalServerRegistrationConfig, diff --git a/packages/vana-sdk/src/index.node.ts b/packages/vana-sdk/src/index.node.ts index e6c02bec..72ddf2c6 100644 --- a/packages/vana-sdk/src/index.node.ts +++ b/packages/vana-sdk/src/index.node.ts @@ -161,7 +161,6 @@ export { type BuilderRegistrationMessage, } from "./protocol/eip712"; export { - PERSONAL_SERVER_REGISTRATION_INTENT, PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, personalServerRegistrationDomain, @@ -169,7 +168,6 @@ export { buildPersonalServerRegistrationTypedData, buildPersonalServerRegistrationSignature, registerPersonalServerSignature, - type PersonalServerRegistrationIntent, type PersonalServerRegistrationTypedData, type PersonalServerRegistrationSigner, type PersonalServerRegistrationDomainInput, @@ -196,8 +194,11 @@ export { type PersonalServerLiteOwnerBindingSignature, } from "./protocol/personal-server-lite-owner-binding"; export { + ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, AccountPersonalServerRegistrationError, signPersonalServerRegistrationWithAccount, + type AccountPersonalServerRegistrationIntent, + type AccountPersonalServerRegistrationSignature, type AccountPersonalServerRegistrationStatus, type AccountPersonalServerRegistrationRequest, type AccountPersonalServerRegistrationConfig, diff --git a/packages/vana-sdk/src/protocol/personal-server-registration.test.ts b/packages/vana-sdk/src/protocol/personal-server-registration.test.ts index 04e6298a..33a89f81 100644 --- a/packages/vana-sdk/src/protocol/personal-server-registration.test.ts +++ b/packages/vana-sdk/src/protocol/personal-server-registration.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest"; import { PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, - PERSONAL_SERVER_REGISTRATION_INTENT, buildPersonalServerRegistrationSignature, buildPersonalServerRegistrationTypedData, createViemPersonalServerRegistrationSigner, @@ -129,8 +128,8 @@ describe("Personal Server registration", () => { expect(result).toMatchObject({ signature: SIGNATURE, signerAddress: OWNER_ADDRESS, - intent: PERSONAL_SERVER_REGISTRATION_INTENT, }); + expect(result).not.toHaveProperty("intent"); expect(result.typedData.message.ownerAddress).toBe(OWNER_ADDRESS); }); @@ -149,7 +148,6 @@ describe("Personal Server registration", () => { }), ).resolves.toMatchObject({ signature: SIGNATURE, - intent: PERSONAL_SERVER_REGISTRATION_INTENT, }); }); diff --git a/packages/vana-sdk/src/protocol/personal-server-registration.ts b/packages/vana-sdk/src/protocol/personal-server-registration.ts index eb1c36d6..b543f74b 100644 --- a/packages/vana-sdk/src/protocol/personal-server-registration.ts +++ b/packages/vana-sdk/src/protocol/personal-server-registration.ts @@ -23,16 +23,10 @@ import { type ServerRegistrationMessage, } from "./eip712"; -export const PERSONAL_SERVER_REGISTRATION_INTENT = - "personal_server.server_registration.v1" as const; - export const PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID = 1480; export const PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT = "0x1483B1F634DBA75AeaE60da7f01A679aabd5ee2c" as const; -export type PersonalServerRegistrationIntent = - typeof PERSONAL_SERVER_REGISTRATION_INTENT; - export type PersonalServerRegistrationTypedData = TypedDataDefinition< typeof SERVER_REGISTRATION_TYPES, "ServerRegistration" @@ -84,7 +78,6 @@ export interface PersonalServerRegistrationSignature { signature: Hex; signerAddress: Address; typedData: PersonalServerRegistrationTypedData; - intent: PersonalServerRegistrationIntent; } export interface PersonalServerRegistrationDomainInput { @@ -199,7 +192,6 @@ export async function buildPersonalServerRegistrationSignature( signature, signerAddress: input.signer.address, typedData, - intent: PERSONAL_SERVER_REGISTRATION_INTENT, }; } From 5a10005bf4fe1a64380d5c2b7e1a83860f25b06e Mon Sep 17 00:00:00 2001 From: Tim Nunamak Date: Thu, 14 May 2026 10:28:24 -0500 Subject: [PATCH 6/8] fix pr prerelease workflow input --- .github/workflows/prerelease.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 5fadbba2..02c15315 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -28,9 +28,11 @@ jobs: - name: Resolve PR head id: pr uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ inputs.pr_number }} with: script: | - const prNumber = Number(core.getInput('pr_number')); + const prNumber = Number(process.env.PR_NUMBER); const { owner, repo } = context.repo; const { data: pull } = await github.rest.pulls.get({ owner, From 68ec21e0f0df37d815cf4f2840398cf9c0f2b9ad Mon Sep 17 00:00:00 2001 From: Tim Nunamak Date: Thu, 14 May 2026 10:41:36 -0500 Subject: [PATCH 7/8] fix(sdk): make deep esm subpaths node-resolvable --- packages/vana-sdk/package.json | 4 +- .../scripts/fix-esm-import-extensions.ts | 92 +++++++++++++++++++ .../scripts/validate-package-imports.ts | 53 +++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 packages/vana-sdk/scripts/fix-esm-import-extensions.ts create mode 100644 packages/vana-sdk/scripts/validate-package-imports.ts diff --git a/packages/vana-sdk/package.json b/packages/vana-sdk/package.json index 08f57e3e..efd15bcc 100644 --- a/packages/vana-sdk/package.json +++ b/packages/vana-sdk/package.json @@ -87,11 +87,13 @@ "build:node": "tsup --config tsup.config.ts", "build:browser": "tsup --config tsup-browser.config.ts", "build:entries": "tsx scripts/bundle-entry-points.ts", - "build": "npm run clean && npm run build:types && npm run build:node && npm run build:browser && npm run build:entries", + "build:fix-esm": "tsx scripts/fix-esm-import-extensions.ts", + "build": "npm run clean && npm run build:types && npm run build:node && npm run build:browser && npm run build:entries && npm run build:fix-esm", "dev": "npm run build -- --watch", "lint": "eslint .", "lint:fix": "eslint . --fix", "typecheck": "tsc -p tsconfig.json", + "validate:package-imports": "npm run build && tsx scripts/validate-package-imports.ts", "validate:types": "rimraf test-dist && tsc -p tsconfig.build.json --outDir test-dist && test -f test-dist/index.node.d.ts && test -f test-dist/index.browser.d.ts && echo '✅ Type declarations can be generated' && rimraf test-dist || (echo '❌ Failed to generate type declarations' && rimraf test-dist && exit 1)", "test": "vitest", "test:verbose": "vitest --reporter=verbose", diff --git a/packages/vana-sdk/scripts/fix-esm-import-extensions.ts b/packages/vana-sdk/scripts/fix-esm-import-extensions.ts new file mode 100644 index 00000000..8f513e78 --- /dev/null +++ b/packages/vana-sdk/scripts/fix-esm-import-extensions.ts @@ -0,0 +1,92 @@ +#!/usr/bin/env tsx +import { + existsSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, extname, join, relative, resolve } from "node:path"; + +const distDir = resolve(process.cwd(), "dist"); +const specifierPattern = + /(\bfrom\s*["']|import\s*\(\s*["'])(\.{1,2}\/[^"']+)(["'])/g; + +function collectJsFiles(dir: string): string[] { + const files: string[] = []; + + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + const stat = statSync(path); + + if (stat.isDirectory()) { + files.push(...collectJsFiles(path)); + continue; + } + + if (path.endsWith(".js")) { + files.push(path); + } + } + + return files; +} + +function hasExplicitTarget(specifier: string): boolean { + const extension = extname(specifier); + return extension !== ""; +} + +function resolveEsmTarget(importer: string, specifier: string): string | null { + if (hasExplicitTarget(specifier)) { + return null; + } + + const basePath = resolve(dirname(importer), specifier); + if (existsSync(`${basePath}.js`)) { + return `${specifier}.js`; + } + + if (existsSync(join(basePath, "index.js"))) { + return `${specifier}/index.js`; + } + + return null; +} + +if (!existsSync(distDir)) { + throw new Error( + "dist directory does not exist. Run the build before fixing ESM imports.", + ); +} + +let filesChanged = 0; +let importsChanged = 0; + +for (const file of collectJsFiles(distDir)) { + const original = readFileSync(file, "utf8"); + let fileImportsChanged = 0; + + const updated = original.replace( + specifierPattern, + (match, prefix: string, specifier: string, suffix: string) => { + const target = resolveEsmTarget(file, specifier); + if (!target) { + return match; + } + + fileImportsChanged += 1; + return `${prefix}${target}${suffix}`; + }, + ); + + if (updated !== original) { + writeFileSync(file, updated); + filesChanged += 1; + importsChanged += fileImportsChanged; + } +} + +console.log( + `Fixed ${importsChanged} ESM import specifier${importsChanged === 1 ? "" : "s"} in ${filesChanged} file${filesChanged === 1 ? "" : "s"} under ${relative(process.cwd(), distDir)}`, +); diff --git a/packages/vana-sdk/scripts/validate-package-imports.ts b/packages/vana-sdk/scripts/validate-package-imports.ts new file mode 100644 index 00000000..70c75d47 --- /dev/null +++ b/packages/vana-sdk/scripts/validate-package-imports.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env tsx +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; + +const imports = [ + "@opendatalabs/vana-sdk/protocol/personal-server-registration", + "@opendatalabs/vana-sdk/account/personal-server-registration", + "@opendatalabs/vana-sdk/browser", +]; + +function run(command: string, args: string[], cwd: string): string { + return execFileSync(command, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +const tempDir = mkdtempSync(join(tmpdir(), "vana-sdk-package-imports-")); + +try { + const packOutput = run( + "npm", + ["pack", "--pack-destination", tempDir], + process.cwd(), + ); + const tarball = join(tempDir, packOutput.trim().split(/\r?\n/).at(-1) ?? ""); + const consumerDir = join(tempDir, "consumer"); + + run("npm", ["init", "-y"], tempDir); + run( + "npm", + ["install", "--prefix", consumerDir, "--no-audit", "--no-fund", tarball], + tempDir, + ); + + for (const specifier of imports) { + run( + "node", + [ + "--input-type=module", + "-e", + `await import(${JSON.stringify(specifier)})`, + ], + consumerDir, + ); + console.log(`✓ ${specifier}`); + } +} finally { + rmSync(tempDir, { recursive: true, force: true }); +} From 1e0eb06ff208f1d7994c9484eb1f3dc38a842708 Mon Sep 17 00:00:00 2001 From: Tim Nunamak Date: Thu, 14 May 2026 11:30:51 -0500 Subject: [PATCH 8/8] fix(account): validate returned registration typed data --- .../scripts/validate-package-imports.ts | 2 + .../personal-server-registration.test.ts | 92 +++++++++++++++++ .../account/personal-server-registration.ts | 99 +++++++++++++++++++ 3 files changed, 193 insertions(+) diff --git a/packages/vana-sdk/scripts/validate-package-imports.ts b/packages/vana-sdk/scripts/validate-package-imports.ts index 70c75d47..971dd392 100644 --- a/packages/vana-sdk/scripts/validate-package-imports.ts +++ b/packages/vana-sdk/scripts/validate-package-imports.ts @@ -6,7 +6,9 @@ import { execFileSync } from "node:child_process"; const imports = [ "@opendatalabs/vana-sdk/protocol/personal-server-registration", + "@opendatalabs/vana-sdk/protocol/personal-server-lite-owner-binding", "@opendatalabs/vana-sdk/account/personal-server-registration", + "@opendatalabs/vana-sdk/account/personal-server-lite-owner-binding", "@opendatalabs/vana-sdk/browser", ]; diff --git a/packages/vana-sdk/src/account/personal-server-registration.test.ts b/packages/vana-sdk/src/account/personal-server-registration.test.ts index eac6348b..ab8875a6 100644 --- a/packages/vana-sdk/src/account/personal-server-registration.test.ts +++ b/packages/vana-sdk/src/account/personal-server-registration.test.ts @@ -232,6 +232,65 @@ describe("Account Personal Server registration integration", () => { }); }); + it("rejects signed typed data that does not match the Account signer", async () => { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: "0x3333333333333333333333333333333333333333", + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "signed", + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + typedData, + }), + ); + + await expect( + signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ), + ).rejects.toThrow( + "Account typedData ownerAddress must match the expected signer address", + ); + }); + + it("rejects confirmation typed data that does not match the requested server", async () => { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: "0x3333333333333333333333333333333333333333", + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "confirmation_required", + signerAddress: OWNER_ADDRESS, + typedData, + }), + ); + + await expect( + signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ), + ).rejects.toThrow( + "Account typedData serverAddress must match the requested serverAddress", + ); + }); + it("uses a fallback signer for returned confirmation typed data when provided", async () => { const typedData = buildPersonalServerRegistrationTypedData({ ownerAddress: OWNER_ADDRESS, @@ -272,6 +331,39 @@ describe("Account Personal Server registration integration", () => { }); }); + it("rejects fallback typed data before signing when the owner does not match the fallback signer", async () => { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: "0x3333333333333333333333333333333333333333", + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + const fallbackSigner: PersonalServerRegistrationSigner = { + address: OWNER_ADDRESS, + signTypedData: vi.fn().mockResolvedValue(SIGNATURE), + }; + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "confirmation_required", + typedData, + }), + ); + + await expect( + signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl, fallbackSigner }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ), + ).rejects.toThrow( + "Account typedData ownerAddress must match the expected signer address", + ); + expect(fallbackSigner.signTypedData).not.toHaveBeenCalled(); + }); + it("preserves structured Account error details", async () => { const fetchImpl = vi .fn() diff --git a/packages/vana-sdk/src/account/personal-server-registration.ts b/packages/vana-sdk/src/account/personal-server-registration.ts index 0d5ebaf7..ff5e1783 100644 --- a/packages/vana-sdk/src/account/personal-server-registration.ts +++ b/packages/vana-sdk/src/account/personal-server-registration.ts @@ -15,7 +15,9 @@ import { type PersonalServerRegistrationSignature, type PersonalServerRegistrationSigner, type PersonalServerRegistrationTypedData, + personalServerRegistrationDomain, } from "../protocol/personal-server-registration"; +import { SERVER_REGISTRATION_TYPES } from "../protocol/eip712"; export const ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT = "personal_server.server_registration.v1" as const; @@ -216,6 +218,13 @@ function buildSignedResult( request: AccountPersonalServerRegistrationRequest, ): AccountPersonalServerRegistrationSignature { assertAddress(response.signerAddress, "signerAddress"); + if (response.typedData) { + assertTypedDataMatchesRequest( + response.typedData, + request, + response.signerAddress, + ); + } return { signature: response.signature, @@ -230,6 +239,90 @@ function buildSignedResult( }; } +function assertTypedDataMatchesRequest( + typedData: PersonalServerRegistrationTypedData, + request: AccountPersonalServerRegistrationRequest, + expectedSignerAddress?: Address, +): void { + assertAddress( + typedData.message.ownerAddress, + "typedData.message.ownerAddress", + ); + assertAddress( + typedData.message.serverAddress, + "typedData.message.serverAddress", + ); + + if ( + expectedSignerAddress && + !sameAddress(typedData.message.ownerAddress, expectedSignerAddress) + ) { + throw new Error( + "Account typedData ownerAddress must match the expected signer address", + ); + } + + if (!sameAddress(typedData.message.serverAddress, request.serverAddress)) { + throw new Error( + "Account typedData serverAddress must match the requested serverAddress", + ); + } + + if (typedData.message.publicKey !== request.serverPublicKey) { + throw new Error( + "Account typedData publicKey must match the requested serverPublicKey", + ); + } + + if (typedData.message.serverUrl !== request.serverUrl) { + throw new Error( + "Account typedData serverUrl must match the requested serverUrl", + ); + } + + if (typedData.primaryType !== "ServerRegistration") { + throw new Error("Account typedData primaryType must be ServerRegistration"); + } + + if ( + JSON.stringify(typedData.types) !== + JSON.stringify(SERVER_REGISTRATION_TYPES) + ) { + throw new Error("Account typedData types must be ServerRegistration types"); + } + + const expectedDomain = personalServerRegistrationDomain({ + config: request.config, + chainId: request.chainId, + verifyingContract: request.verifyingContract, + }); + if (!domainsEqual(typedData.domain, expectedDomain)) { + throw new Error("Account typedData domain must match the requested domain"); + } +} + +function sameAddress(a: Address, b: Address): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +function domainsEqual( + a: PersonalServerRegistrationTypedData["domain"], + b: PersonalServerRegistrationTypedData["domain"], +): boolean { + if (!a || !b) { + return false; + } + + return ( + a.name === b.name && + a.version === b.version && + Number(a.chainId) === Number(b.chainId) && + String(a.verifyingContract ?? "").toLowerCase() === + String(b.verifyingContract ?? "").toLowerCase() && + a.salt === b.salt + ); +} + export async function signPersonalServerRegistrationWithAccount( config: AccountPersonalServerRegistrationConfig, request: AccountPersonalServerRegistrationRequest, @@ -284,6 +377,7 @@ export async function signPersonalServerRegistrationWithAccount( "Account confirmation_required response must include typedData", ); } + assertTypedDataMatchesRequest(body.typedData, request, body.signerAddress); if (!config.fallbackSigner) { return { @@ -293,6 +387,11 @@ export async function signPersonalServerRegistrationWithAccount( }; } + assertTypedDataMatchesRequest( + body.typedData, + request, + config.fallbackSigner.address, + ); const signature = await config.fallbackSigner.signTypedData(body.typedData); return {