From ba7041b697caa8f5fb715aa6383f666eb60a7172 Mon Sep 17 00:00:00 2001 From: Nathan Liow Date: Fri, 9 Oct 2026 17:45:55 -0500 Subject: [PATCH 1/6] fix(auth): Validate signup email and deprecate wallet project creation Deprecate createProject, payUSDC and PAYMENT_AMOUNT: the backend no longer lets wallet JWTs create projects, so new accounts must go through signup / signupAndPay, which require email, firstName and lastName. Add an email format check for new signups and rewrite the AGENTS.md auth example around signup / signupAndPay. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 23 +++++++++++++---------- CHANGELOG.md | 10 ++++++++++ src/auth/constants.ts | 6 +++++- src/auth/createProject.ts | 3 +++ src/auth/payUSDC.ts | 5 +++++ src/auth/signup.ts | 5 +++++ src/auth/tests/signup.test.ts | 8 ++++++++ src/auth/types.ts | 8 ++++++++ 8 files changed, 57 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a6b2132b..5dce2c09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -435,19 +435,22 @@ helius.zk.getValidityProof({ hashes }) // Validity proof ### Auth -The auth namespace is available on the main `HeliusClient` as `helius.auth.*`. It is used for programmatic agent signup flows. The step-by-step flow requires signing an auth message first to obtain a JWT, then using that JWT for all subsequent API calls. +The auth namespace is available on the main `HeliusClient` as `helius.auth.*`. It is used for programmatic agent signup flows. New accounts are created only through `signup` / `signupAndPay`, which require `email`, `firstName`, and `lastName`; the wallet is used to authenticate and pay. `createProject` and `payUSDC` are deprecated — the backend no longer creates projects for wallet-only sign-ins. ```typescript -// Step-by-step flow (JWT-based): const keypair = await helius.auth.generateKeypair(); // Generate Ed25519 keypair -const address = await helius.auth.getAddress(keypair); // Get wallet address (async) -const { message, signature } = await helius.auth.signAuthMessage(keypair.secretKey); // Sign auth message -const { token } = await helius.auth.walletSignup(message, signature, address); // Get JWT via signup -const projects = await helius.auth.listProjects(token); // List projects (needs JWT) -const project = await helius.auth.createProject(token); // Create project (needs JWT) -const apiKey = await helius.auth.createApiKey(token, project.id, address); // Create API key (needs JWT) - -// Or use the all-in-one shortcut (signs the auth message, creates a hosted + +// Hosted checkout: returns a payment link the user opens in a browser to pay +// with any wallet. Contact info is required. +const link = await helius.auth.signup({ + secretKey: keypair.secretKey, + plan: "agent", + email: "you@example.com", + firstName: "Jane", + lastName: "Doe", +}); + +// Or the all-in-one shortcut (signs the auth message, creates a hosted // checkout, auto-pays USDC + memo from the local keypair, polls activation): const result = await helius.auth.signupAndPay({ secretKey: keypair.secretKey, diff --git a/CHANGELOG.md b/CHANGELOG.md index e6f6a0bc..163d7605 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +## Unreleased + +### Changed + +- **Signup validates email format** — when creating a new account, `signup` and `signupAndPay` now reject a malformed `email` before creating a payment intent, in addition to the existing `email` / `firstName` / `lastName` presence check. Wallets that already have a project are unaffected. + +### Deprecated + +- **`createProject`, `payUSDC`, and `PAYMENT_AMOUNT`** — the legacy 1 USDC wallet-only project-creation flow. The backend no longer creates projects for wallet-only sign-ins; use `signup` / `signupAndPay`. These will be removed in the next major version. + ## [v3.2.0](https://github.com/helius-labs/helius-sdk/compare/v3.1.0...v3.2.0) - 2026-09-08 Version 1 transactions made execution-ready for the Agave 4.2 mainnet activation, a transport augmentation hook, and correct on-chain-failure reporting from confirmation polling. diff --git a/src/auth/constants.ts b/src/auth/constants.ts index ea39ab33..4c5d6bbf 100644 --- a/src/auth/constants.ts +++ b/src/auth/constants.ts @@ -32,7 +32,11 @@ export const USDC_MINT_DEVNET = export const USDC_MINT = USDC_MINT_MAINNET; -/** Legacy: 1 USDC (6 decimals). Only used by payUSDC. */ +/** + * Legacy: 1 USDC (6 decimals). Only used by payUSDC. + * + * @deprecated Wallet-created projects are no longer supported by the backend. Use `signup` / `signupAndPay` (requires `email`, `firstName`, `lastName`). + */ export const PAYMENT_AMOUNT = 1_000_000n; /** diff --git a/src/auth/createProject.ts b/src/auth/createProject.ts index 818921ec..42334018 100644 --- a/src/auth/createProject.ts +++ b/src/auth/createProject.ts @@ -1,6 +1,9 @@ import type { Project } from "./types"; import { authRequest } from "./utils"; +/** + * @deprecated Wallet-created projects are no longer supported by the backend. Use `signup` / `signupAndPay` (requires `email`, `firstName`, `lastName`). + */ export async function createProject( jwt: string, userAgent?: string diff --git a/src/auth/payUSDC.ts b/src/auth/payUSDC.ts index 027042a1..8f0dbc37 100644 --- a/src/auth/payUSDC.ts +++ b/src/auth/payUSDC.ts @@ -1,6 +1,11 @@ import { TREASURY, USDC_MINT, PAYMENT_AMOUNT } from "./constants"; import { buildAndSendTokenTransfer } from "./buildTokenTransfer"; +/** + * Legacy 1 USDC unlock-fee transfer for wallet-created projects. + * + * @deprecated Wallet-created projects are no longer supported by the backend. Use `signup` / `signupAndPay` (requires `email`, `firstName`, `lastName`). + */ export async function payUSDC(secretKey: Uint8Array): Promise { return buildAndSendTokenTransfer({ secretKey, diff --git a/src/auth/signup.ts b/src/auth/signup.ts index 109dfcc5..d42dd2c2 100644 --- a/src/auth/signup.ts +++ b/src/auth/signup.ts @@ -35,6 +35,8 @@ const validatePlan = (plan: string): SupportedPlan => { return normalized; }; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + /** * Conservative same-plan match. For `agent`, period is ignored. Subscription * plans require both family AND period to match confidently — otherwise treat @@ -133,6 +135,9 @@ export const signup = async (options: SignupOptions): Promise => { .join(", "); throw new Error(`Signup requires contact info. Missing: ${missing}.`); } + if (!EMAIL_PATTERN.test(options.email)) { + throw new Error(`Invalid email address: ${options.email}`); + } const paymentLink = await createPayment({ jwt, diff --git a/src/auth/tests/signup.test.ts b/src/auth/tests/signup.test.ts index e38a5d6a..26a612aa 100644 --- a/src/auth/tests/signup.test.ts +++ b/src/auth/tests/signup.test.ts @@ -137,6 +137,14 @@ describe("signup", () => { ).rejects.toThrow(/contact info/); }); + it("rejects a malformed email when creating a fresh intent", async () => { + mockListProjects.mockResolvedValue([]); + await expect( + signup({ ...baseOpts, email: "not-an-email" }) + ).rejects.toThrow(/Invalid email address/); + expect(mockInitializeCheckout).not.toHaveBeenCalled(); + }); + it("does NOT require contact info when wallet already has a matching project", async () => { // already_subscribed short-circuit must not demand email/firstName/lastName. mockListProjects.mockResolvedValue([ diff --git a/src/auth/types.ts b/src/auth/types.ts index 2d324ae9..9c3475fe 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -212,11 +212,13 @@ export interface AuthClient { address: string ): Promise; listProjects(jwt: string): Promise; + /** @deprecated Wallet-created projects are no longer supported by the backend. Use `signup` / `signupAndPay` (requires `email`, `firstName`, `lastName`). */ createProject(jwt: string): Promise; getProject(jwt: string, id: string): Promise; createApiKey(jwt: string, projectId: string, wallet: string): Promise; checkSolBalance(address: string): Promise; checkUsdcBalance(address: string): Promise; + /** @deprecated Wallet-created projects are no longer supported by the backend. Use `signup` / `signupAndPay` (requires `email`, `firstName`, `lastName`). */ payUSDC(secretKey: Uint8Array): Promise; initializeCheckout( jwt: string, @@ -350,8 +352,11 @@ export interface SecretKeySignupOptions { plan: SupportedPlan; /** Ignored for `plan: "agent"`. Defaults to `"monthly"` for paid subscription plans. */ period?: "monthly" | "yearly"; + /** Required when creating a new account; not needed if the wallet already has a project. */ email?: string; + /** Required when creating a new account. */ firstName?: string; + /** Required when creating a new account. */ lastName?: string; couponCode?: string; /** Override the hosted-page host. See {@link resolvePaymentHost}. */ @@ -369,8 +374,11 @@ export interface PreauthenticatedSignupOptions { walletAddress: string; plan: SupportedPlan; period?: "monthly" | "yearly"; + /** Required when creating a new account; not needed if the wallet already has a project. */ email?: string; + /** Required when creating a new account. */ firstName?: string; + /** Required when creating a new account. */ lastName?: string; couponCode?: string; paymentHost?: string; From ec08dd24e7a6ed902501c868a2ed483fe0fc6405 Mon Sep 17 00:00:00 2001 From: Nathan Liow Date: Fri, 9 Oct 2026 18:25:25 -0500 Subject: [PATCH 2/6] fix(auth): Make payUSDC throw and fix AGENTS.md auth examples payUSDC now throws without sending funds, since the backend no longer creates projects for wallet-only sign-ins. Trim email before validating and stop echoing it in the error. AGENTS.md: read paymentLink from the payment_required result and restore the existing-wallet key recovery steps. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 22 +++++- CHANGELOG.md | 1 + src/auth/payUSDC.ts | 19 ++--- src/auth/signup.ts | 7 +- src/auth/tests/payUSDC.test.ts | 135 ++------------------------------- 5 files changed, 39 insertions(+), 145 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5dce2c09..eaf38916 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -440,15 +440,18 @@ The auth namespace is available on the main `HeliusClient` as `helius.auth.*`. I ```typescript const keypair = await helius.auth.generateKeypair(); // Generate Ed25519 keypair -// Hosted checkout: returns a payment link the user opens in a browser to pay -// with any wallet. Contact info is required. -const link = await helius.auth.signup({ +// Hosted checkout. Contact info is required when the wallet has no account yet. +const signupResult = await helius.auth.signup({ secretKey: keypair.secretKey, plan: "agent", email: "you@example.com", firstName: "Jane", lastName: "Doe", }); +if (signupResult.kind === "payment_required") { + // Open in a browser and pay with any wallet. + console.log(signupResult.paymentLink.paymentUrl); +} // Or the all-in-one shortcut (signs the auth message, creates a hosted // checkout, auto-pays USDC + memo from the local keypair, polls activation): @@ -467,6 +470,19 @@ const result = await helius.auth.signupAndPay({ // "expired" / "failed" — payment never completed; inspect `paymentIntentId` (and `reason` for failed) ``` +Recovering the API key for a wallet that already has an account (no contact info needed): + +```typescript +const address = await helius.auth.getAddress(keypair); // Wallet address +const { message, signature } = await helius.auth.signAuthMessage(keypair.secretKey); // Sign auth message +const { token } = await helius.auth.walletSignup(message, signature, address); // Get JWT +const [project] = await helius.auth.listProjects(token); // Existing project +const details = await helius.auth.getProject(token, project.id); // Includes apiKeys +const apiKey = + details.apiKeys?.[0]?.keyId ?? + (await helius.auth.createApiKey(token, project.id, address)).keyId; // Create one if none +``` + ## Documentation - Full API Reference: https://docs.helius.dev diff --git a/CHANGELOG.md b/CHANGELOG.md index 163d7605..057269d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). ### Changed - **Signup validates email format** — when creating a new account, `signup` and `signupAndPay` now reject a malformed `email` before creating a payment intent, in addition to the existing `email` / `firstName` / `lastName` presence check. Wallets that already have a project are unaffected. +- **`payUSDC` no longer sends funds** — it now throws. The backend no longer creates projects for wallet-only sign-ins, so the 1 USDC unlock fee would buy nothing. ### Deprecated diff --git a/src/auth/payUSDC.ts b/src/auth/payUSDC.ts index 8f0dbc37..a5df61a3 100644 --- a/src/auth/payUSDC.ts +++ b/src/auth/payUSDC.ts @@ -1,16 +1,13 @@ -import { TREASURY, USDC_MINT, PAYMENT_AMOUNT } from "./constants"; -import { buildAndSendTokenTransfer } from "./buildTokenTransfer"; - /** * Legacy 1 USDC unlock-fee transfer for wallet-created projects. * - * @deprecated Wallet-created projects are no longer supported by the backend. Use `signup` / `signupAndPay` (requires `email`, `firstName`, `lastName`). + * Always throws without sending funds: the backend no longer creates projects + * for wallet-only sign-ins, so the fee would buy nothing. + * + * @deprecated Use `signup` / `signupAndPay` (requires `email`, `firstName`, `lastName`). */ -export async function payUSDC(secretKey: Uint8Array): Promise { - return buildAndSendTokenTransfer({ - secretKey, - recipientAddress: TREASURY, - mintAddress: USDC_MINT, - amount: PAYMENT_AMOUNT, - }); +export async function payUSDC(_secretKey: Uint8Array): Promise { + throw new Error( + "payUSDC is no longer supported: wallet-created projects were removed. Use signup or signupAndPay with email, firstName and lastName." + ); } diff --git a/src/auth/signup.ts b/src/auth/signup.ts index d42dd2c2..b8e4f8e7 100644 --- a/src/auth/signup.ts +++ b/src/auth/signup.ts @@ -135,8 +135,9 @@ export const signup = async (options: SignupOptions): Promise => { .join(", "); throw new Error(`Signup requires contact info. Missing: ${missing}.`); } - if (!EMAIL_PATTERN.test(options.email)) { - throw new Error(`Invalid email address: ${options.email}`); + const email = options.email.trim(); + if (!EMAIL_PATTERN.test(email)) { + throw new Error("Invalid email address."); } const paymentLink = await createPayment({ @@ -144,7 +145,7 @@ export const signup = async (options: SignupOptions): Promise => { refId, plan, period: options.period, - email: options.email, + email, firstName: options.firstName, lastName: options.lastName, couponCode: options.couponCode, diff --git a/src/auth/tests/payUSDC.test.ts b/src/auth/tests/payUSDC.test.ts index ded5ef3f..369b9620 100644 --- a/src/auth/tests/payUSDC.test.ts +++ b/src/auth/tests/payUSDC.test.ts @@ -1,136 +1,15 @@ -const mockSend = jest.fn(); -const mockRpcSubscriptions = { subscribe: jest.fn() }; -const mockSendAndConfirm = jest.fn().mockResolvedValue(undefined); -const mockSigner = { - address: "SignerAddress1111111111111111111111111111111", - signTransactions: jest.fn(), -}; - -jest.mock("@solana/kit", () => { - const actual = jest.requireActual("@solana/kit"); - return { - ...actual, - createSolanaRpc: jest.fn(() => ({ - getLatestBlockhash: jest.fn(() => ({ - send: mockSend, - })), - })), - createSolanaRpcSubscriptions: jest.fn(() => mockRpcSubscriptions), - sendAndConfirmTransactionFactory: jest.fn(() => mockSendAndConfirm), - createKeyPairSignerFromBytes: jest.fn().mockResolvedValue(mockSigner), - pipe: jest.fn((...args: any[]) => { - let result = args[0]; - for (let i = 1; i < args.length; i++) { - result = args[i](result); - } - return result; - }), - createTransactionMessage: jest.fn(() => ({ instructions: [] })), - setTransactionMessageFeePayerSigner: jest.fn((_, tx) => tx), - setTransactionMessageLifetimeUsingBlockhash: jest.fn((_, tx) => tx), - appendTransactionMessageInstructions: jest.fn((_, tx) => tx), - signTransactionMessageWithSigners: jest.fn().mockResolvedValue({ - signatures: { - SignerAddress1111111111111111111111111111111: new Uint8Array(64), - }, - }), - getSignatureFromTransaction: jest.fn(() => "tx-signature-abc123"), - }; -}); - -jest.mock("@solana-program/token", () => ({ - findAssociatedTokenPda: jest - .fn() - .mockResolvedValue(["MockAta111111111111111111111111111111111111"]), - TOKEN_PROGRAM_ADDRESS: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - getTransferInstruction: jest.fn(() => ({ programAddress: "token" })), +const mockBuildAndSend = jest.fn(); +jest.mock("../buildTokenTransfer", () => ({ + buildAndSendTokenTransfer: mockBuildAndSend, })); import { payUSDC } from "../payUSDC"; -import { - createKeyPairSignerFromBytes, - getSignatureFromTransaction, -} from "@solana/kit"; -import { - findAssociatedTokenPda, - getTransferInstruction, -} from "@solana-program/token"; - -const MOCK_SECRET_KEY = new Uint8Array(64); describe("payUSDC", () => { - beforeEach(() => { - jest.clearAllMocks(); - mockSend.mockResolvedValue({ - value: { - blockhash: "mockBlockhash11111111111111111111111111111111", - lastValidBlockHeight: 100n, - }, - }); - }); - - it("creates a signer from the secret key", async () => { - await payUSDC(MOCK_SECRET_KEY); - - expect(createKeyPairSignerFromBytes).toHaveBeenCalledWith(MOCK_SECRET_KEY); - }); - - it("derives ATAs for sender and treasury", async () => { - await payUSDC(MOCK_SECRET_KEY); - - expect(findAssociatedTokenPda).toHaveBeenCalledTimes(2); - // Sender ATA - expect(findAssociatedTokenPda).toHaveBeenCalledWith( - expect.objectContaining({ owner: mockSigner.address }) - ); - // Treasury ATA - expect(findAssociatedTokenPda).toHaveBeenCalledWith( - expect.objectContaining({ - owner: "CEs84tEowsXpH8u4VBf8rJSVgSRypFMfXw9CpGRtQgb6", - }) - ); - }); - - it("fetches latest blockhash", async () => { - await payUSDC(MOCK_SECRET_KEY); - - expect(mockSend).toHaveBeenCalled(); - }); - - it("builds a transfer instruction with PAYMENT_AMOUNT", async () => { - await payUSDC(MOCK_SECRET_KEY); - - expect(getTransferInstruction).toHaveBeenCalledWith( - expect.objectContaining({ amount: 1_000_000n }) - ); - }); - - it("sends and confirms the transaction", async () => { - await payUSDC(MOCK_SECRET_KEY); - - expect(mockSendAndConfirm).toHaveBeenCalledWith(expect.anything(), { - commitment: "confirmed", - }); - }); - - it("returns the transaction signature", async () => { - const result = await payUSDC(MOCK_SECRET_KEY); - - expect(result).toBe("tx-signature-abc123"); - expect(getSignatureFromTransaction).toHaveBeenCalled(); - }); - - it("propagates RPC errors", async () => { - mockSend.mockRejectedValue(new Error("RPC unavailable")); - - await expect(payUSDC(MOCK_SECRET_KEY)).rejects.toThrow("RPC unavailable"); - }); - - it("propagates send-and-confirm failures", async () => { - mockSendAndConfirm.mockRejectedValue(new Error("Transaction failed")); - - await expect(payUSDC(MOCK_SECRET_KEY)).rejects.toThrow( - "Transaction failed" + it("throws without sending any funds", async () => { + await expect(payUSDC(new Uint8Array(64))).rejects.toThrow( + /no longer supported/ ); + expect(mockBuildAndSend).not.toHaveBeenCalled(); }); }); From 4fec5be706be6cade576791b8daa1773ff2529f4 Mon Sep 17 00:00:00 2001 From: Nathan Liow Date: Fri, 9 Oct 2026 18:41:37 -0500 Subject: [PATCH 3/6] fix(auth): Trim signup names and reject whitespace-only ones Co-Authored-By: Claude Opus 5.5 (1M context) --- src/auth/signup.ts | 16 +++++++++------- src/auth/tests/signup.test.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/auth/signup.ts b/src/auth/signup.ts index b8e4f8e7..eed019bc 100644 --- a/src/auth/signup.ts +++ b/src/auth/signup.ts @@ -125,17 +125,19 @@ export const signup = async (options: SignupOptions): Promise => { // the backend at /checkout/initialize for any new subscription, so we // validate up front (here, not at the top of signup) to give callers a // crisp error before the network round trip. - if (!options.email || !options.firstName || !options.lastName) { + const email = options.email?.trim() ?? ""; + const firstName = options.firstName?.trim() ?? ""; + const lastName = options.lastName?.trim() ?? ""; + if (!email || !firstName || !lastName) { const missing = [ - !options.email && "email", - !options.firstName && "firstName", - !options.lastName && "lastName", + !email && "email", + !firstName && "firstName", + !lastName && "lastName", ] .filter(Boolean) .join(", "); throw new Error(`Signup requires contact info. Missing: ${missing}.`); } - const email = options.email.trim(); if (!EMAIL_PATTERN.test(email)) { throw new Error("Invalid email address."); } @@ -146,8 +148,8 @@ export const signup = async (options: SignupOptions): Promise => { plan, period: options.period, email, - firstName: options.firstName, - lastName: options.lastName, + firstName, + lastName, couponCode: options.couponCode, walletAddress, paymentHost: options.paymentHost, diff --git a/src/auth/tests/signup.test.ts b/src/auth/tests/signup.test.ts index 26a612aa..25ed560f 100644 --- a/src/auth/tests/signup.test.ts +++ b/src/auth/tests/signup.test.ts @@ -145,6 +145,14 @@ describe("signup", () => { expect(mockInitializeCheckout).not.toHaveBeenCalled(); }); + it("treats whitespace-only names as missing and trims contact info", async () => { + mockListProjects.mockResolvedValue([]); + await expect( + signup({ ...baseOpts, firstName: " ", lastName: " " }) + ).rejects.toThrow(/Missing: firstName, lastName/); + expect(mockInitializeCheckout).not.toHaveBeenCalled(); + }); + it("does NOT require contact info when wallet already has a matching project", async () => { // already_subscribed short-circuit must not demand email/firstName/lastName. mockListProjects.mockResolvedValue([ From 85602a2b89aa092d3bc4e899e51a5a210c1c51b6 Mon Sep 17 00:00:00 2001 From: Nathan Liow Date: Fri, 9 Oct 2026 18:46:00 -0500 Subject: [PATCH 4/6] fix(auth): Share contact-field normalization between signup and upgrade Add normalizeContactField, which trims strings and treats non-strings or whitespace-only values as missing. Use it in signup and upgradePlan, and test that trimmed values reach checkout. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/auth/signup.ts | 8 ++++---- src/auth/signupHelpers.ts | 10 ++++++++++ src/auth/tests/signup.test.ts | 28 ++++++++++++++++++++++++++++ src/auth/tests/upgradePlan.test.ts | 17 +++++++++++++++++ src/auth/upgradePlan.ts | 7 ++++--- 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/auth/signup.ts b/src/auth/signup.ts index eed019bc..f63d60dd 100644 --- a/src/auth/signup.ts +++ b/src/auth/signup.ts @@ -6,7 +6,7 @@ import { walletSignup } from "./walletSignup"; import { listProjects } from "./listProjects"; import { getProject } from "./getProject"; import { createApiKey } from "./createApiKey"; -import { buildEndpoints } from "./signupHelpers"; +import { buildEndpoints, normalizeContactField } from "./signupHelpers"; import { createPayment } from "./createPayment"; import type { Endpoints, @@ -125,9 +125,9 @@ export const signup = async (options: SignupOptions): Promise => { // the backend at /checkout/initialize for any new subscription, so we // validate up front (here, not at the top of signup) to give callers a // crisp error before the network round trip. - const email = options.email?.trim() ?? ""; - const firstName = options.firstName?.trim() ?? ""; - const lastName = options.lastName?.trim() ?? ""; + const email = normalizeContactField(options.email); + const firstName = normalizeContactField(options.firstName); + const lastName = normalizeContactField(options.lastName); if (!email || !firstName || !lastName) { const missing = [ !email && "email", diff --git a/src/auth/signupHelpers.ts b/src/auth/signupHelpers.ts index 2626e80c..a182069f 100644 --- a/src/auth/signupHelpers.ts +++ b/src/auth/signupHelpers.ts @@ -4,3 +4,13 @@ export function buildEndpoints(apiKey: string) { devnet: `https://devnet.helius-rpc.com/?api-key=${apiKey}`, }; } + +/** + * Trims a contact field. Returns `undefined` for non-strings and for strings + * that are empty after trimming, so callers can treat both as "not provided". + */ +export function normalizeContactField(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed === "" ? undefined : trimmed; +} diff --git a/src/auth/tests/signup.test.ts b/src/auth/tests/signup.test.ts index 25ed560f..cef1ccf6 100644 --- a/src/auth/tests/signup.test.ts +++ b/src/auth/tests/signup.test.ts @@ -153,6 +153,34 @@ describe("signup", () => { expect(mockInitializeCheckout).not.toHaveBeenCalled(); }); + it("sends trimmed contact info to checkout", async () => { + mockListProjects.mockResolvedValue([]); + mockGetCheckoutPreview.mockResolvedValue(preview); + mockInitializeCheckout.mockResolvedValue(intent); + + await signup({ + ...baseOpts, + email: " a@b.com ", + firstName: " Ada ", + lastName: " Lovelace ", + }); + + expect(mockInitializeCheckout.mock.calls[0][1]).toEqual( + expect.objectContaining({ + email: "a@b.com", + firstName: "Ada", + lastName: "Lovelace", + }) + ); + }); + + it("treats non-string contact fields as missing", async () => { + mockListProjects.mockResolvedValue([]); + await expect( + signup({ ...baseOpts, firstName: 123 as never }) + ).rejects.toThrow(/Missing: firstName/); + }); + it("does NOT require contact info when wallet already has a matching project", async () => { // already_subscribed short-circuit must not demand email/firstName/lastName. mockListProjects.mockResolvedValue([ diff --git a/src/auth/tests/upgradePlan.test.ts b/src/auth/tests/upgradePlan.test.ts index 016da30c..9515eef9 100644 --- a/src/auth/tests/upgradePlan.test.ts +++ b/src/auth/tests/upgradePlan.test.ts @@ -77,6 +77,23 @@ describe("upgradePlan — link mode", () => { ); }); + it("trims contact info and drops whitespace-only names", async () => { + mockInitializeCheckout.mockResolvedValue(intent); + + await upgradePlan({ + ...baseOpts, + email: " a@b.com ", + firstName: " ", + lastName: " Lovelace ", + }); + + const sent = mockInitializeCheckout.mock.calls[0][1]; + expect(sent).toEqual( + expect.objectContaining({ email: "a@b.com", lastName: "Lovelace" }) + ); + expect(sent.firstName).toBeUndefined(); + }); + it("does not require contact info (backend auto-fetches from existing customer)", async () => { mockGetCheckoutPreview.mockRejectedValue( new Error("API error (400): Customer ID is required for one time preview") diff --git a/src/auth/upgradePlan.ts b/src/auth/upgradePlan.ts index d75c84d2..f827a43d 100644 --- a/src/auth/upgradePlan.ts +++ b/src/auth/upgradePlan.ts @@ -1,3 +1,4 @@ +import { normalizeContactField } from "./signupHelpers"; import { createPayment } from "./createPayment"; import { payPaymentLink } from "./payPaymentLink"; import { pollUntilTerminal } from "./pollPayment"; @@ -22,9 +23,9 @@ export const upgradePlan = async ( refId: options.projectId, plan: options.plan, period: options.period, - email: options.email, - firstName: options.firstName, - lastName: options.lastName, + email: normalizeContactField(options.email), + firstName: normalizeContactField(options.firstName), + lastName: normalizeContactField(options.lastName), couponCode: options.couponCode, paymentHost: options.paymentHost, }); From 933e0423851817eb4686d2ee3d8a6b6f7d00180a Mon Sep 17 00:00:00 2001 From: Nathan Liow Date: Fri, 9 Oct 2026 18:48:40 -0500 Subject: [PATCH 5/6] chore(bundle): Opt deprecated auth modules out of the growth ratchet Co-Authored-By: Claude Opus 5.5 (1M context) --- bundlemon.config.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/bundlemon.config.js b/bundlemon.config.js index 25c37e34..028af3d2 100644 --- a/bundlemon.config.js +++ b/bundlemon.config.js @@ -15,6 +15,24 @@ export default { path: 'dist/esm/auth/client.js', maxSize: '2.5kb', }, + { + // Opt out of the default 15% growth ratchet: the deprecation JSDoc on + // createProject / payUSDC (wallet-created projects were removed) is kept + // in the output, and payUSDC now throws instead of sending USDC. These + // modules are tiny, so a few comment lines exceed 15%. + path: 'dist/esm/auth/createProject.js', + maxSize: '1kb', + }, + { + path: 'dist/esm/auth/payUSDC.js', + maxSize: '1kb', + }, + { + // Opt out of the default 15% growth ratchet: adds normalizeContactField, + // shared by signup and upgradePlan. + path: 'dist/esm/auth/signupHelpers.js', + maxSize: '1kb', + }, { path: 'dist/esm/auth/constants.js', maxSize: '1.5kb', From 1420c108fb733a0b7a029c6697f8b2fcb004d485 Mon Sep 17 00:00:00 2001 From: Nathan Liow Date: Fri, 9 Oct 2026 18:54:38 -0500 Subject: [PATCH 6/6] fix(auth): Validate upgrade contact info like signup Move EMAIL_PATTERN and the contact checks into a shared requireContactInfo helper. signup always uses it for new accounts; upgradePlan uses it whenever any of email/firstName/lastName is passed, so partial or whitespace-only names and malformed emails fail with the same errors instead of being dropped or sent as-is. Co-Authored-By: Claude Opus 5.5 (1M context) --- bundlemon.config.js | 8 ++++++- src/auth/signup.ts | 21 ++--------------- src/auth/signupHelpers.ts | 36 ++++++++++++++++++++++++++++ src/auth/tests/upgradePlan.test.ts | 38 +++++++++++++++++++++++++----- src/auth/upgradePlan.ts | 18 ++++++++++---- 5 files changed, 90 insertions(+), 31 deletions(-) diff --git a/bundlemon.config.js b/bundlemon.config.js index 028af3d2..b2d478ef 100644 --- a/bundlemon.config.js +++ b/bundlemon.config.js @@ -29,10 +29,16 @@ export default { }, { // Opt out of the default 15% growth ratchet: adds normalizeContactField, - // shared by signup and upgradePlan. + // requireContactInfo and EMAIL_PATTERN, shared by signup and upgradePlan. path: 'dist/esm/auth/signupHelpers.js', maxSize: '1kb', }, + { + // Opt out of the default 15% growth ratchet: upgradePlan now validates + // contact info (shared requireContactInfo) when any field is passed. + path: 'dist/esm/auth/upgradePlan.js', + maxSize: '1.5kb', + }, { path: 'dist/esm/auth/constants.js', maxSize: '1.5kb', diff --git a/src/auth/signup.ts b/src/auth/signup.ts index f63d60dd..9db13915 100644 --- a/src/auth/signup.ts +++ b/src/auth/signup.ts @@ -6,7 +6,7 @@ import { walletSignup } from "./walletSignup"; import { listProjects } from "./listProjects"; import { getProject } from "./getProject"; import { createApiKey } from "./createApiKey"; -import { buildEndpoints, normalizeContactField } from "./signupHelpers"; +import { buildEndpoints, requireContactInfo } from "./signupHelpers"; import { createPayment } from "./createPayment"; import type { Endpoints, @@ -35,8 +35,6 @@ const validatePlan = (plan: string): SupportedPlan => { return normalized; }; -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - /** * Conservative same-plan match. For `agent`, period is ignored. Subscription * plans require both family AND period to match confidently — otherwise treat @@ -125,22 +123,7 @@ export const signup = async (options: SignupOptions): Promise => { // the backend at /checkout/initialize for any new subscription, so we // validate up front (here, not at the top of signup) to give callers a // crisp error before the network round trip. - const email = normalizeContactField(options.email); - const firstName = normalizeContactField(options.firstName); - const lastName = normalizeContactField(options.lastName); - if (!email || !firstName || !lastName) { - const missing = [ - !email && "email", - !firstName && "firstName", - !lastName && "lastName", - ] - .filter(Boolean) - .join(", "); - throw new Error(`Signup requires contact info. Missing: ${missing}.`); - } - if (!EMAIL_PATTERN.test(email)) { - throw new Error("Invalid email address."); - } + const { email, firstName, lastName } = requireContactInfo(options, "Signup"); const paymentLink = await createPayment({ jwt, diff --git a/src/auth/signupHelpers.ts b/src/auth/signupHelpers.ts index a182069f..b46020e2 100644 --- a/src/auth/signupHelpers.ts +++ b/src/auth/signupHelpers.ts @@ -14,3 +14,39 @@ export function normalizeContactField(value: unknown): string | undefined { const trimmed = value.trim(); return trimmed === "" ? undefined : trimmed; } + +export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export interface ContactInfo { + email: string; + firstName: string; + lastName: string; +} + +/** + * Normalizes `email`, `firstName` and `lastName` and requires all three. + * Throws `" requires contact info. Missing: ..."` when any is absent + * (after trimming) and `"Invalid email address."` for a malformed email. + */ +export function requireContactInfo( + fields: { email?: unknown; firstName?: unknown; lastName?: unknown }, + context: string +): ContactInfo { + const email = normalizeContactField(fields.email); + const firstName = normalizeContactField(fields.firstName); + const lastName = normalizeContactField(fields.lastName); + if (!email || !firstName || !lastName) { + const missing = [ + !email && "email", + !firstName && "firstName", + !lastName && "lastName", + ] + .filter(Boolean) + .join(", "); + throw new Error(`${context} requires contact info. Missing: ${missing}.`); + } + if (!EMAIL_PATTERN.test(email)) { + throw new Error("Invalid email address."); + } + return { email, firstName, lastName }; +} diff --git a/src/auth/tests/upgradePlan.test.ts b/src/auth/tests/upgradePlan.test.ts index 9515eef9..82309b1f 100644 --- a/src/auth/tests/upgradePlan.test.ts +++ b/src/auth/tests/upgradePlan.test.ts @@ -77,21 +77,47 @@ describe("upgradePlan — link mode", () => { ); }); - it("trims contact info and drops whitespace-only names", async () => { + it("trims contact info before sending it", async () => { mockInitializeCheckout.mockResolvedValue(intent); await upgradePlan({ ...baseOpts, email: " a@b.com ", - firstName: " ", + firstName: " Ada ", lastName: " Lovelace ", }); - const sent = mockInitializeCheckout.mock.calls[0][1]; - expect(sent).toEqual( - expect.objectContaining({ email: "a@b.com", lastName: "Lovelace" }) + expect(mockInitializeCheckout.mock.calls[0][1]).toEqual( + expect.objectContaining({ + email: "a@b.com", + firstName: "Ada", + lastName: "Lovelace", + }) ); - expect(sent.firstName).toBeUndefined(); + }); + + it("rejects partial contact info like signup does", async () => { + await expect( + upgradePlan({ + ...baseOpts, + email: "a@b.com", + firstName: " ", + lastName: "Lovelace", + }) + ).rejects.toThrow(/Missing: firstName/); + expect(mockInitializeCheckout).not.toHaveBeenCalled(); + }); + + it("rejects a malformed email", async () => { + await expect( + upgradePlan({ + ...baseOpts, + email: "not-an-email", + firstName: "Ada", + lastName: "Lovelace", + }) + ).rejects.toThrow(/Invalid email address/); + expect(mockInitializeCheckout).not.toHaveBeenCalled(); }); it("does not require contact info (backend auto-fetches from existing customer)", async () => { diff --git a/src/auth/upgradePlan.ts b/src/auth/upgradePlan.ts index f827a43d..e453f107 100644 --- a/src/auth/upgradePlan.ts +++ b/src/auth/upgradePlan.ts @@ -1,4 +1,4 @@ -import { normalizeContactField } from "./signupHelpers"; +import { requireContactInfo } from "./signupHelpers"; import { createPayment } from "./createPayment"; import { payPaymentLink } from "./payPaymentLink"; import { pollUntilTerminal } from "./pollPayment"; @@ -13,19 +13,27 @@ import type { * Phase 2 — create a payment intent for upgrading an existing project to a * new plan, and return a hosted-checkout link the user can open in a * browser. Contact info is optional; the backend auto-fetches it from the - * project's existing Stripe customer. + * project's existing Stripe customer. If any of `email` / `firstName` / + * `lastName` is passed, all three are required and validated like `signup`. */ export const upgradePlan = async ( options: UpgradePlanOptions ): Promise => { + const contactProvided = + options.email !== undefined || + options.firstName !== undefined || + options.lastName !== undefined; + const contact = contactProvided + ? requireContactInfo(options, "Upgrade") + : undefined; const paymentLink = await createPayment({ jwt: options.jwt, refId: options.projectId, plan: options.plan, period: options.period, - email: normalizeContactField(options.email), - firstName: normalizeContactField(options.firstName), - lastName: normalizeContactField(options.lastName), + email: contact?.email, + firstName: contact?.firstName, + lastName: contact?.lastName, couponCode: options.couponCode, paymentHost: options.paymentHost, });