UNPKG

better-near-auth

Version:

Sign in with NEAR (SIWN) plugin for Better Auth

588 lines (587 loc) 18.9 kB
import { Near, fromNearConnect, generateNonce } from "near-kit"; import { hex } from "@scure/base"; import { atom } from "nanostores"; import { z } from "zod"; import { AccountIdSchema } from "near-kit/schemas"; //#region \0rolldown/runtime.js var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports); var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { key = keys[i]; if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: ((k) => from[k]).bind(null, key), enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod)); //#endregion //#region src/types.ts const socialImageSchema = z.object({ url: z.string().optional(), ipfs_cid: z.string().optional() }); const profileSchema = z.object({ name: z.string().optional(), description: z.string().optional(), image: socialImageSchema.optional(), backgroundImage: socialImageSchema.optional(), linktree: z.record(z.string(), z.string()).optional() }); const signedMessageSchema = z.object({ accountId: z.string(), publicKey: z.string(), signature: z.string(), state: z.string().optional() }); z.object({ signedMessage: signedMessageSchema, message: z.string(), recipient: z.string(), nonce: z.string(), accountId: AccountIdSchema, callbackUrl: z.string().optional() }); z.object({ accountId: AccountIdSchema, network: z.enum(["mainnet", "testnet"]).optional() }); z.object({ accountId: AccountIdSchema, networkId: z.union([z.literal("mainnet"), z.literal("testnet")]) }); z.object({ signedMessage: signedMessageSchema, message: z.string(), recipient: z.string(), nonce: z.string(), accountId: AccountIdSchema, callbackUrl: z.string().optional() }); z.object({ payload: z.string() }); z.object({ txHash: z.string(), status: z.enum([ "pending", "completed", "failed" ]) }); z.object({ status: z.enum([ "pending", "completed", "failed" ]), gasUsed: z.string().optional(), outcome: z.unknown().optional() }); z.object({ contractId: z.string(), methodName: z.string(), args: z.record(z.string(), z.any()).optional() }); z.object({ nonce: z.string() }); z.object({ token: z.string(), success: z.literal(true), user: z.object({ id: z.string(), accountId: AccountIdSchema, network: z.union([z.literal("mainnet"), z.literal("testnet")]) }) }); profileSchema.nullable(); z.object({ result: z.unknown() }); z.object({ accountId: AccountIdSchema.optional() }); const RelayedTransactionSchema = z.object({ id: z.string(), userId: z.string(), txHash: z.string(), senderId: z.string(), receiverId: z.string(), network: z.string(), status: z.string(), gasUsed: z.string().optional(), createdAt: z.string(), updatedAt: z.string().optional() }); z.object({ transactions: z.array(RelayedTransactionSchema) }); z.object({ network: z.enum(["mainnet", "testnet"]).optional() }); const SUB_ACCOUNT_LABEL_REGEX = /^([a-z\d]+[-_])*[a-z\d]+$/; const SubAccountNameSchema = z.string().min(2, "Sub-account name must be at least 2 characters").max(64, "Sub-account name must be at most 64 characters").regex(SUB_ACCOUNT_LABEL_REGEX, "Sub-account name must contain only lowercase alphanumeric characters, hyphens, and underscores (no leading/trailing hyphens or underscores)"); z.object({ subAccountName: SubAccountNameSchema, network: z.enum(["mainnet", "testnet"]).optional(), publicKey: z.string() }); z.object({ success: z.literal(true), accountId: z.string(), network: z.enum(["mainnet", "testnet"]), publicKey: z.string(), message: z.string() }); z.object({ subAccountName: SubAccountNameSchema, network: z.enum(["mainnet", "testnet"]).optional() }); z.object({ available: z.boolean(), accountId: z.string(), parentAccount: z.string().optional(), reason: z.enum([ "taken", "invalid", "too-long", "not-configured" ]).optional() }); //#endregion //#region src/client.ts const siwnClient = (config) => { const nearState = atom(null); const walletConnected = atom(false); const activeNetwork = atom(config.networkId || "mainnet"); const getRecipient = (network) => { const net = network || activeNetwork.get(); if (config.recipients) return config.recipients[net]; return config.recipient ?? ""; }; const getSupportedNetworks = () => { if (config.recipients) return ["mainnet", "testnet"]; if (config.recipient) return [config.recipient.endsWith(".testnet") ? "testnet" : "mainnet"]; return ["mainnet"]; }; let connectors = /* @__PURE__ */ new Map(); let nearClients = /* @__PURE__ */ new Map(); let initializedNetworks = /* @__PURE__ */ new Set(); let connectorModulePromise = null; let initPromises = /* @__PURE__ */ new Map(); const loadConnector = async () => { connectorModulePromise ??= import("./build.js").then((m) => /* @__PURE__ */ __toESM(m.default, 1)); const { NearConnector } = await connectorModulePromise; return NearConnector; }; const handleAccountConnection = async (accountId, publicKey, network) => { if (!accountId) return; const net = network || activeNetwork.get(); nearState.set({ accountId, publicKey: publicKey || null, networkId: net }); walletConnected.set(true); }; const initClientForNetwork = async (network, $fetch) => { if (initializedNetworks.has(network)) return true; if (initPromises.has(network)) return initPromises.get(network); if (typeof globalThis.window === "undefined") return false; const initPromise = (async () => { const connector = new (await (loadConnector()))({ network, cspNonce: config.cspNonce }); connectors.set(network, connector); const near = new Near({ network, wallet: fromNearConnect(connector) }); nearClients.set(network, near); connector.on("wallet:signIn", async (data) => { const accountId = data.accounts?.[0]?.accountId; const publicKey = data.accounts?.[0]?.publicKey; if (accountId) await handleAccountConnection(accountId, publicKey, network); }); connector.on("wallet:signOut", () => { walletConnected.set(false); const state = nearState.get(); if (state) nearState.set({ accountId: state.accountId, publicKey: null, networkId: state.networkId }); }); connector.getConnectedWallet().then(({ accounts }) => { const account = accounts?.[0]; const net = activeNetwork.get(); if (account?.accountId && !nearState.get()) nearState.set({ accountId: account.accountId, publicKey: account.publicKey ?? null, networkId: net }); if (account?.accountId) walletConnected.set(true); }).catch(() => {}); initializedNetworks.add(network); if ($fetch) restoreFromSession($fetch); return true; })(); initPromises.set(network, initPromise); try { return await initPromise; } finally { initPromises.delete(network); } }; const initClient = async ($fetch) => { return initClientForNetwork(activeNetwork.get(), $fetch); }; let sessionRestored = false; const restoreFromSession = async ($fetch) => { if (sessionRestored) return; if (nearState.get()?.accountId) { sessionRestored = true; return; } try { const res = await $fetch("/near/list-accounts", { method: "GET" }); const accounts = res.data?.accounts; if (accounts?.length) { const primary = res.data?.activeAccount || accounts.find((a) => a.isPrimary) || accounts[0]; if (primary) { nearState.set({ accountId: primary.accountId, publicKey: primary.publicKey ?? null, networkId: primary.network }); activeNetwork.set(primary.network); } } } catch {} sessionRestored = true; }; const requireConnector = async (network) => { const net = network || activeNetwork.get(); await initClientForNetwork(net); const connector = connectors.get(net); if (!connector) throw new Error(`Wallet not initialized for ${net} — this operation requires a browser environment`); return connector; }; const requireNear = (network) => { const net = network || activeNetwork.get(); const client = nearClients.get(net); if (!client) throw new Error(`Wallet not initialized for ${net} — this operation requires a browser environment`); return client; }; const ensureWalletConnected = async (network) => { const net = network || activeNetwork.get(); const conn = await requireConnector(net); if (walletConnected.get()) try { const { accounts } = await conn.getConnectedWallet(); if (accounts?.length) return true; } catch {} return new Promise((resolve) => { const signInHandler = (data) => { const accountId = data.accounts?.[0]?.accountId; const publicKey = data.accounts?.[0]?.publicKey; if (accountId) { handleAccountConnection(accountId, publicKey, net); resolve(true); } }; conn.on("wallet:signIn", signInHandler); conn.connect().catch(() => {}).finally(() => { conn.off("wallet:signIn", signInHandler); if (!walletConnected.get()) resolve(false); }); }); }; const signWithWallet = async () => { const net = activeNetwork.get(); const conn = await requireConnector(net); const nearClient = requireNear(net); const recipient = getRecipient(net); const nonceBytes = generateNonce(); const nonceHex = hex.encode(nonceBytes); const message = `Sign in to ${recipient}`; let connectedWallet = null; try { connectedWallet = await conn.getConnectedWallet(); } catch {} if (connectedWallet?.accounts?.length) { const isTestnetAccount = connectedWallet.accounts[0].accountId.endsWith(".testnet"); if (!(net === "testnet" === isTestnetAccount)) connectedWallet = null; } if (connectedWallet?.accounts?.length) { const signedMessage = await nearClient.signMessage({ message, recipient, nonce: nonceBytes }); if (!signedMessage?.accountId) throw new Error("Wallet sign-in was cancelled or failed"); return { signedMessage, accountId: signedMessage.accountId, publicKey: signedMessage.publicKey, nonceHex }; } const result = { value: null }; const handler = (data) => { const account = data.accounts?.[0]; if (account?.signedMessage) result.value = { signedMessage: account.signedMessage, accountId: account.accountId, publicKey: account.signedMessage.publicKey }; }; conn.on("wallet:signInAndSignMessage", handler); try { await conn.connect({ signMessageParams: { message, recipient, nonce: nonceBytes } }); } finally { conn.off("wallet:signInAndSignMessage", handler); } if (!result.value) throw new Error("Wallet sign-in was cancelled or failed"); return { signedMessage: result.value.signedMessage, accountId: result.value.accountId, publicKey: result.value.publicKey, nonceHex }; }; const buildSignedDelegateActionInternal = async (receiverId, buildActions) => { const net = activeNetwork.get(); const state = nearState.get(); if (!state?.accountId) throw new Error("No NEAR account found — please sign in with your NEAR wallet"); if (!walletConnected.get()) { if (!await ensureWalletConnected(net)) throw new Error("Wallet connection required — please approve the connection to sign"); } const { payload } = await buildActions(requireNear(net).transaction(state.accountId), receiverId).delegate(); return payload; }; return { id: "siwn", $InferServerPlugin: {}, getAtoms: (_$fetch) => ({ nearState, walletConnected, activeNetwork }), getActions: ($fetch, _$store, _options) => { initClient($fetch); return { near: { nonce: async (params, fetchOptions) => { return await $fetch("/near/nonce", { method: "POST", body: params, ...fetchOptions }); }, verify: async (params, fetchOptions) => { return await $fetch("/near/verify", { method: "POST", body: params, ...fetchOptions }); }, getProfile: async (accountId, fetchOptions) => { return await $fetch("/near/profile", { method: "POST", body: { accountId }, ...fetchOptions }); }, view: async (params, fetchOptions) => { return await $fetch("/near/view", { method: "POST", body: params, ...fetchOptions }); }, getAccountId: () => { return nearState.get()?.accountId || null; }, getState: () => nearState.get(), isWalletConnected: () => walletConnected.get(), ensureConnected: async () => { const net = activeNetwork.get(); if (!initializedNetworks.has(net)) { if (!await initClientForNetwork(net)) return false; } if (walletConnected.get()) try { const conn = connectors.get(net); if (conn) { const { accounts } = await conn.getConnectedWallet(); if (accounts?.length) return true; } } catch (err) { console.error("[siwn] restoreFromSession failed:", err instanceof Error ? err.message : err); } return ensureWalletConnected(net); }, disconnect: async () => { for (const [_net, conn] of connectors) if (conn) try { await conn.disconnect(); } catch {} walletConnected.set(false); nearState.set(null); }, link: async (callbacks) => { const net = activeNetwork.get(); const recipient = getRecipient(net); try { const { signedMessage, accountId, nonceHex } = await signWithWallet(); const message = `Sign in to ${recipient}`; await handleAccountConnection(accountId, signedMessage.publicKey, net); const linkResponse = await $fetch("/near/link-account", { method: "POST", body: { signedMessage, message, recipient, nonce: nonceHex, accountId } }); if (linkResponse.error) throw new Error(linkResponse.error.message || "Failed to link NEAR account"); if (!linkResponse?.data?.success) throw new Error("Account linking failed"); callbacks?.onSuccess?.(); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); callbacks?.onError?.(err); } }, unlink: async (params, fetchOptions) => { return await $fetch("/near/unlink-account", { method: "POST", body: params, ...fetchOptions }); }, listAccounts: async () => { return await $fetch("/near/list-accounts", { method: "GET" }); }, setPrimaryAccount: async (params) => { const response = await $fetch("/near/set-primary-account", { method: "POST", body: params }); const activeAccount = response.data?.activeAccount; if (activeAccount) { nearState.set({ accountId: activeAccount.accountId, publicKey: activeAccount.publicKey ?? null, networkId: activeAccount.network }); activeNetwork.set(activeAccount.network); } return response; }, buildSignedDelegateAction: async (receiverId, buildActions) => { return buildSignedDelegateActionInternal(receiverId, buildActions); }, relayTransaction: async (params) => { return await $fetch("/near/relay", { method: "POST", body: params }); }, getRelayStatus: async (txHash) => { return await $fetch(`/near/relay-status/${txHash}`, { method: "GET" }); }, getRelayerInfo: async (params) => { return await $fetch("/near/relayer-info", { method: "POST", body: { ...params ?? {}, network: params?.network ?? activeNetwork.get() } }); }, relayHistory: async () => { return await $fetch("/near/relay-history", { method: "GET" }); }, createSubAccount: async (params) => { return await $fetch("/near/create-sub-account", { method: "POST", body: { ...params, network: params.network ?? activeNetwork.get() } }); }, checkSubAccountAvailability: async (params) => { if (!SUB_ACCOUNT_LABEL_REGEX.test(params.subAccountName) || params.subAccountName.length < 2) return { data: { available: false, accountId: "", reason: "invalid" }, error: null }; return await $fetch("/near/check-sub-account-availability", { method: "POST", body: { ...params, network: params.network ?? activeNetwork.get() } }); }, setNetwork: (network) => { const prev = activeNetwork.get(); if (prev !== network) { const oldConn = connectors.get(prev); if (oldConn) oldConn.disconnect().catch(() => {}); walletConnected.set(false); nearState.set(null); } activeNetwork.set(network); initClientForNetwork(network); }, getNetwork: () => activeNetwork.get(), getSupportedNetworks: () => getSupportedNetworks(), getRecipient: (network) => getRecipient(network), get client() { const net = activeNetwork.get(); const client = nearClients.get(net); if (!client) throw new Error(`Wallet not initialized for ${net} — this operation requires a browser environment`); return client; } }, signIn: { near: async (callbacks) => { try { const { signedMessage, accountId, nonceHex } = await signWithWallet(); const net = activeNetwork.get(); const recipient = getRecipient(net); const message = `Sign in to ${recipient}`; await handleAccountConnection(accountId, signedMessage.publicKey, net); const verifyResponse = await $fetch("/near/verify", { method: "POST", body: { signedMessage, message, recipient, nonce: nonceHex, accountId } }); if (verifyResponse.error) throw new Error(verifyResponse.error.message || "Failed to verify signature"); if (!verifyResponse?.data?.success) throw new Error("Authentication verification failed"); callbacks?.onSuccess?.(); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); callbacks?.onError?.(err); } } } }; } }; }; //#endregion export { siwnClient, __commonJSMin as t }; //# sourceMappingURL=client.js.map