UNPKG

better-near-auth

Version:

Sign in with NEAR (SIWN) plugin for Better Auth

1,269 lines (1,268 loc) 39.6 kB
import { APIError, createAuthEndpoint, createAuthMiddleware, sessionMiddleware } from "better-auth/api"; import { setSessionCookie } from "better-auth/cookies"; import { InMemoryKeyStore, Near, RotatingKeyStore, decodeSignedDelegateAction, generateKey, generateNonce, parseKey, verifyNep413Signature } from "near-kit"; import { base58, hex } from "@scure/base"; import z$1, { z } from "zod"; import { AccountIdSchema } from "near-kit/schemas"; //#region src/profile.ts const FALLBACK_URL = "https://ipfs.near.social/ipfs/bafkreidn5fb2oygegqaldx7ycdmhu4owcrmoxd7ekbzfmeakkobz2ja7qy"; function getNetworkFromAccountId(accountId) { return accountId.endsWith(".testnet") ? "testnet" : "mainnet"; } function getImageUrl(image, fallback) { if (image?.url) return image.url; if (image?.ipfs_cid) return `https://ipfs.near.social/ipfs/${image.ipfs_cid}`; return fallback || FALLBACK_URL; } async function defaultGetProfile(accountId, apiKey) { const network = getNetworkFromAccountId(accountId); try { const kvUrl = network === "testnet" ? "https://kv.test.fastnear.com" : "https://kv.main.fastnear.com"; const effectiveApiKey = apiKey || process.env.FASTNEAR_API_KEY; const headers = { "Content-Type": "application/json" }; if (effectiveApiKey) headers["Authorization"] = `Bearer ${effectiveApiKey}`; const response = await fetch(`${kvUrl}/v0/latest/social.near/${accountId}/profile/**`, { headers }); if (response.ok) { const entry = (await response.json())?.entries?.[0]; if (entry?.value) try { const profile = typeof entry.value === "string" ? JSON.parse(entry.value) : entry.value; if (profile?.name || profile?.description || profile?.image) return { name: profile.name, description: profile.description, image: profile.image, backgroundImage: profile.backgroundImage, linktree: profile.linktree }; } catch {} } const apiBase = { mainnet: "https://api.near.social", testnet: "https://test.api.near.social" }[network]; const keys = [`${accountId}/profile/**`]; const fallbackResponse = await fetch(`${apiBase}/get`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ keys }) }); if (!fallbackResponse.ok) throw new Error(`HTTP error! status: ${fallbackResponse.status}`); const profile = (await fallbackResponse.json())?.[accountId]?.profile; if (profile) return { name: profile.name, description: profile.description, image: profile.image, backgroundImage: profile.backgroundImage, linktree: profile.linktree }; return null; } catch (error) { return null; } } //#endregion //#region src/schema.ts const schema = { nearAccount: { fields: { userId: { type: "string", references: { model: "user", field: "id" }, required: true }, accountId: { type: "string", required: true }, network: { type: "string", required: true }, publicKey: { type: "string", required: true }, isPrimary: { type: "boolean", defaultValue: false }, createdAt: { type: "date", required: true } } }, relayedTransaction: { fields: { userId: { type: "string", references: { model: "user", field: "id" } }, txHash: { type: "string", required: true }, senderId: { type: "string", required: true }, receiverId: { type: "string", required: true }, network: { type: "string", required: true }, status: { type: "string", required: true }, gasUsed: { type: "string" }, createdAt: { type: "date", required: true }, updatedAt: { type: "date" } } }, relayerKey: { fields: { accountId: { type: "string", required: true }, encryptedPrivateKey: { type: "string", required: true }, iv: { type: "string", required: true }, publicKey: { type: "string", required: true }, network: { type: "string", required: true }, createdAt: { type: "date", required: true }, lastUsedAt: { type: "date" } } } }; //#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() }); const LinkAccountRequest = z.object({ signedMessage: signedMessageSchema, message: z.string(), recipient: z.string(), nonce: z.string(), accountId: AccountIdSchema }); const SetPrimaryAccountRequest = z.object({ accountId: AccountIdSchema, network: z.enum(["mainnet", "testnet"]).optional() }); const NonceRequest = z.object({ accountId: AccountIdSchema, networkId: z.union([z.literal("mainnet"), z.literal("testnet")]) }); const VerifyRequest = z.object({ signedMessage: signedMessageSchema, message: z.string(), recipient: z.string(), nonce: z.string(), accountId: AccountIdSchema }); const RelayRequest = z.object({ payload: z.string() }); const RelayResponse = z.object({ txHash: z.string(), status: z.enum([ "pending", "completed", "failed" ]) }); const RelayStatusResponse = z.object({ status: z.enum([ "pending", "completed", "failed" ]), gasUsed: z.string().optional(), outcome: z.unknown().optional() }); const ViewContractRequest = z.object({ contractId: z.string(), methodName: z.string(), args: z.record(z.string(), z.any()).optional() }); const NonceResponse = z.object({ nonce: z.string() }); const VerifyResponse = 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")]) }) }); const ProfileResponse = profileSchema.nullable(); const ViewContractResponse = z.object({ result: z.unknown() }); const ProfileRequest = 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() }); const RelayHistoryResponse = z.object({ transactions: z.array(RelayedTransactionSchema) }); //#endregion //#region src/utils.ts function bytesToBase64(bytes) { return btoa(String.fromCharCode(...bytes)); } function base64ToBytes(base64) { return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); } function bytesToHex(bytes) { return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); } async function deriveAesKey(secret) { const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HKDF" }, false, ["deriveKey"]); return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("better-near-auth-relayer"), info: new Uint8Array(0) }, keyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); } async function encryptPrivateKey(privateKey, secret) { const aesKey = await deriveAesKey(secret); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, privateKey); return { encrypted: bytesToBase64(new Uint8Array(encrypted)), iv: bytesToBase64(iv) }; } async function decryptPrivateKey(encrypted, iv, secret) { const aesKey = await deriveAesKey(secret); const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv: base64ToBytes(iv) }, aesKey, base64ToBytes(encrypted)); return new Uint8Array(decrypted); } //#endregion //#region src/index.ts async function hashNonce(nonce) { const data = new TextEncoder().encode(Array.from(nonce).map((b) => b.toString(16).padStart(2, "0")).join("")); const hashBuffer = await crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join(""); } function deriveEmail(accountId) { if (accountId.endsWith(".near")) return `${accountId.slice(0, -5)}@near.email`; return null; } function nearAccountKey(account) { return `${account.accountId}:${account.network}`; } function getCreatedAtTime(account) { return account.createdAt instanceof Date ? account.createdAt.getTime() : new Date(account.createdAt).getTime(); } function buildListAccountsResponse(nearAccounts) { const activeAccount = nearAccounts.find((account) => account.isPrimary) ?? nearAccounts[0] ?? null; const activeKey = activeAccount ? nearAccountKey(activeAccount) : null; const accounts = nearAccounts.map((account) => { const isActive = activeKey === nearAccountKey(account); return { ...account, providerId: "siwn", isActive, isAvailable: !isActive }; }).sort((a, b) => { if (a.isActive !== b.isActive) return a.isActive ? -1 : 1; return getCreatedAtTime(a) - getCreatedAtTime(b); }); const listedActiveAccount = accounts.find((account) => account.isActive) ?? null; return { accounts, activeAccount: listedActiveAccount ? { ...listedActiveAccount } : null, availableAccounts: accounts.filter((account) => account.isAvailable).map((account) => ({ ...account })) }; } function createNear(network, headers, rpcUrl, keyStore) { const config = { headers }; if (rpcUrl) config.network = { rpcUrl, networkId: network }; else config.network = network; if (keyStore) config.keyStore = keyStore; return new Near(config); } async function initRelayer(relayerConfig, network, adapter, secret, apiKey, rpcUrl) { if (!relayerConfig) return null; const headers = {}; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; if (relayerConfig.accountId && (relayerConfig.privateKey || relayerConfig.privateKeys)) { const keys = relayerConfig.privateKeys ?? (relayerConfig.privateKey ? [relayerConfig.privateKey] : []); let keyStore; if (keys.length === 1) keyStore = new InMemoryKeyStore({ [relayerConfig.accountId]: keys[0] }); else keyStore = new RotatingKeyStore({ [relayerConfig.accountId]: keys }); return { near: createNear(network, headers, rpcUrl, keyStore), accountId: relayerConfig.accountId, network, mode: "explicit" }; } const existing = await adapter.findOne({ model: "relayerKey", where: [{ field: "network", operator: "eq", value: network }] }); if (existing) { if (!secret) throw new Error("BETTER_AUTH_SECRET required for relayer key decryption"); const privateKeyBytes = await decryptPrivateKey(existing.encryptedPrivateKey, existing.iv, secret); const keyPair = parseKey(`ed25519:${base58.encode(privateKeyBytes)}`); const accountId = bytesToHex(keyPair.publicKey.data); console.log(`[siwn] Relayer recovered: ${accountId} (${network})`); const keyStore = new InMemoryKeyStore(); await keyStore.add(accountId, keyPair); return { near: createNear(network, headers, rpcUrl, keyStore), accountId, network, mode: "ephemeral", createdAt: existing.createdAt, lastUsedAt: existing.lastUsedAt }; } const keyPair = generateKey(); if (!secret) throw new Error("BETTER_AUTH_SECRET required for relayer key encryption"); const privateKeyBytes = keyPair.secretKey.startsWith("ed25519:") ? base58.decode(keyPair.secretKey.slice(8)) : new Uint8Array(0); const publicKeyBase58 = keyPair.publicKey.toString().replace("ed25519:", ""); const accountId = bytesToHex(keyPair.publicKey.data); const { encrypted, iv } = await encryptPrivateKey(privateKeyBytes, secret); await adapter.create({ model: "relayerKey", data: { accountId, encryptedPrivateKey: encrypted, iv, publicKey: `ed25519:${publicKeyBase58}`, network, createdAt: /* @__PURE__ */ new Date() } }); console.log(`[siwn] Relayer created in EPHEMERAL mode: ${accountId} (${network})`); console.log(`[siwn] Fund this account with NEAR to enable gasless relay`); console.log(`[siwn] Private key is encrypted in DB — persists across restarts`); const keyStore = new InMemoryKeyStore(); await keyStore.add(accountId, keyPair); return { near: createNear(network, headers, rpcUrl, keyStore), accountId, network, mode: "ephemeral", createdAt: /* @__PURE__ */ new Date() }; } async function relayOnChain(payload, relayerState) { const userAction = decodeSignedDelegateAction(payload); return { txHash: (await relayerState.near.transaction(relayerState.accountId).signedDelegateAction(userAction).send({ waitUntil: "EXECUTED" })).transaction.hash }; } async function defaultValidateLimitedAccessKey(accountId, publicKey, recipient, near) { const key = await near.getAccessKey(accountId, publicKey); if (!key) return false; if (key.permission === "FullAccess") return true; if ("FunctionCall" in key.permission) return key.permission.FunctionCall.receiver_id === recipient; return false; } const siwn = (options) => { const apiKey = options.apiKey; let relayerState = null; let relayerInitialized = false; const headers = {}; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; const ensureRelayer = async (adapter, secret, network) => { if (relayerInitialized) return relayerState; relayerInitialized = true; relayerState = await initRelayer(options.relayer, network, adapter, secret, apiKey, options.rpcUrl); return relayerState; }; const getNear = (network) => { if (options.rpcUrl) return new Near({ network: { rpcUrl: options.rpcUrl, networkId: network }, headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : void 0 }); return new Near({ network, headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : void 0 }); }; return { id: "siwn", schema, hooks: { after: [{ matcher: (context) => context.path === "/auth/session" && context.method === "GET", handler: createAuthMiddleware(async (ctx) => { const session = ctx.context.session; if (session) { const nearAccount = await ctx.context.adapter.findOne({ model: "nearAccount", where: [{ field: "userId", operator: "eq", value: session.user.id }, { field: "isPrimary", operator: "eq", value: true }] }); if (nearAccount) ctx.context.session = { ...session, user: { ...session.user, nearAccount } }; } return { context: ctx }; }) }] }, endpoints: { linkNearAccount: createAuthEndpoint("/near/link-account", { method: "POST", body: LinkAccountRequest, use: [sessionMiddleware], requireRequest: true }, async (ctx) => { const { signedMessage, message, recipient, nonce, accountId } = ctx.body; const network = getNetworkFromAccountId(accountId); const session = ctx.context.session; if (!session) throw new APIError("UNAUTHORIZED", { message: "Must be logged in to link NEAR account", status: 401 }); try { const near = getNear(network); if (!await verifyNep413Signature(signedMessage, { message, recipient, nonce: hex.decode(nonce) }, { near, maxAge: 900 * 1e3 })) throw new APIError("UNAUTHORIZED", { message: "Unauthorized: Invalid signature", status: 401 }); if (signedMessage.accountId !== accountId) throw new APIError("UNAUTHORIZED", { message: "Unauthorized: Account ID mismatch", status: 401 }); const publicKey = signedMessage.publicKey; if (!options.requireFullAccessKey && options.validateLimitedAccessKey) { if (!await options.validateLimitedAccessKey({ accountId, publicKey, recipient: options.recipient })) throw new APIError("UNAUTHORIZED", { message: "Unauthorized: Invalid function call access key", status: 401 }); } if (await ctx.context.adapter.findOne({ model: "nearAccount", where: [{ field: "accountId", operator: "eq", value: accountId }, { field: "network", operator: "eq", value: network }] })) throw new APIError("BAD_REQUEST", { message: "This NEAR account is already linked to another user", status: 400 }); const existingPrimaryAccount = await ctx.context.adapter.findOne({ model: "nearAccount", where: [{ field: "userId", operator: "eq", value: session.user.id }, { field: "isPrimary", operator: "eq", value: true }] }); await ctx.context.adapter.create({ model: "nearAccount", data: { userId: session.user.id, accountId, network, publicKey, isPrimary: !existingPrimaryAccount, createdAt: /* @__PURE__ */ new Date() } }); await ctx.context.internalAdapter.createAccount({ userId: session.user.id, providerId: "siwn", accountId: `${accountId}:${network}`, createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date() }); return ctx.json({ success: true, accountId, network, message: "NEAR account successfully linked" }); } catch (error) { if (error instanceof APIError) throw error; throw new APIError("UNAUTHORIZED", { message: "Something went wrong. Please try again later.", error: error instanceof Error ? error.message : "Unknown error", status: 401 }); } }), unlinkNearAccount: createAuthEndpoint("/near/unlink-account", { method: "POST", body: z$1.object({ accountId: z$1.string(), network: z$1.enum(["mainnet", "testnet"]).optional() }), use: [sessionMiddleware] }, async (ctx) => { const { accountId, network: providedNetwork } = ctx.body; const session = ctx.context.session; if (!session) throw new APIError("UNAUTHORIZED", { message: "Must be logged in to unlink NEAR account", status: 401 }); const network = providedNetwork || getNetworkFromAccountId(accountId); const nearAccount = await ctx.context.adapter.findOne({ model: "nearAccount", where: [ { field: "userId", operator: "eq", value: session.user.id }, { field: "accountId", operator: "eq", value: accountId }, { field: "network", operator: "eq", value: network } ] }); if (!nearAccount) throw new APIError("NOT_FOUND", { message: "NEAR account not found or not linked to your user", status: 404 }); if ((await ctx.context.adapter.findMany({ model: "account", where: [{ field: "userId", operator: "eq", value: session.user.id }] })).length <= 1) throw new APIError("BAD_REQUEST", { message: "Cannot unlink last authentication method. Link another account first.", status: 400 }); if (nearAccount.isPrimary) { const otherNearAccounts = await ctx.context.adapter.findMany({ model: "nearAccount", where: [{ field: "userId", operator: "eq", value: session.user.id }, { field: "accountId", operator: "ne", value: accountId }] }); if (otherNearAccounts.length > 0) await ctx.context.adapter.update({ model: "nearAccount", where: [{ field: "id", operator: "eq", value: otherNearAccounts[0].id }], update: { isPrimary: true } }); } await ctx.context.adapter.delete({ model: "nearAccount", where: [ { field: "userId", operator: "eq", value: session.user.id }, { field: "accountId", operator: "eq", value: accountId }, { field: "network", operator: "eq", value: network } ] }); const accountToDelete = await ctx.context.adapter.findOne({ model: "account", where: [ { field: "userId", operator: "eq", value: session.user.id }, { field: "providerId", operator: "eq", value: "siwn" }, { field: "accountId", operator: "eq", value: `${accountId}:${network}` } ] }); if (accountToDelete) await ctx.context.internalAdapter.deleteAccount(accountToDelete.id); return ctx.json({ success: true, accountId, network, message: "NEAR account successfully unlinked" }); }), listNearAccounts: createAuthEndpoint("/near/list-accounts", { method: "GET", use: [sessionMiddleware] }, async (ctx) => { const session = ctx.context.session; const nearAccounts = await ctx.context.adapter.findMany({ model: "nearAccount", where: [{ field: "userId", operator: "eq", value: session.user.id }] }); return ctx.json(buildListAccountsResponse(nearAccounts)); }), setPrimaryNearAccount: createAuthEndpoint("/near/set-primary-account", { method: "POST", body: SetPrimaryAccountRequest, use: [sessionMiddleware] }, async (ctx) => { const { accountId, network: providedNetwork } = ctx.body; const session = ctx.context.session; const network = providedNetwork || getNetworkFromAccountId(accountId); const targetAccount = await ctx.context.adapter.findOne({ model: "nearAccount", where: [ { field: "userId", operator: "eq", value: session.user.id }, { field: "accountId", operator: "eq", value: accountId }, { field: "network", operator: "eq", value: network } ] }); if (!targetAccount) throw new APIError("NOT_FOUND", { message: "NEAR account not found or not linked to your user", status: 404 }); const nearAccounts = await ctx.context.adapter.findMany({ model: "nearAccount", where: [{ field: "userId", operator: "eq", value: session.user.id }] }); await Promise.all(nearAccounts.map((account) => ctx.context.adapter.update({ model: "nearAccount", where: [{ field: "id", operator: "eq", value: account.id }], update: { isPrimary: nearAccountKey(account) === nearAccountKey(targetAccount) } }))); const updatedNearAccounts = await ctx.context.adapter.findMany({ model: "nearAccount", where: [{ field: "userId", operator: "eq", value: session.user.id }] }); return ctx.json({ success: true, accountId, network, message: "Primary NEAR account updated", ...buildListAccountsResponse(updatedNearAccounts) }); }), getSiwnNonce: createAuthEndpoint("/near/nonce", { method: "POST", body: NonceRequest }, async (ctx) => { const { accountId, networkId } = ctx.body; const network = getNetworkFromAccountId(accountId); if (networkId !== network) throw new APIError("BAD_REQUEST", { message: "Network ID mismatch with account ID", status: 400 }); if (!await getNear(network).accountExists(accountId)) throw new APIError("BAD_REQUEST", { message: "Account does not exist on-chain", status: 400 }); const nonce = options.getNonce ? await options.getNonce() : generateNonce(); const nonceString = hex.encode(nonce); await ctx.context.internalAdapter.createVerificationValue({ identifier: `siwn:${accountId}:${network}`, value: nonceString, expiresAt: new Date(Date.now() + 900 * 1e3) }); return ctx.json(NonceResponse.parse({ nonce: nonceString })); }), getSiwnProfile: createAuthEndpoint("/near/profile", { method: "POST", body: ProfileRequest, use: [sessionMiddleware] }, async (ctx) => { const { accountId } = ctx.body; let targetAccountId = accountId; if (!targetAccountId) { const session = ctx.context.session; if (!session) throw new APIError("UNAUTHORIZED", { message: "Session required when no accountId provided", status: 401 }); const nearAccount = await ctx.context.adapter.findOne({ model: "nearAccount", where: [{ field: "userId", operator: "eq", value: session.user.id }, { field: "isPrimary", operator: "eq", value: true }] }); if (!nearAccount) throw new APIError("NOT_FOUND", { message: "No NEAR account found for user", status: 404 }); targetAccountId = nearAccount.accountId; } const profile = await (options.getProfile || ((id) => defaultGetProfile(id, apiKey)))(targetAccountId); return ctx.json(ProfileResponse.parse(profile)); }), verifySiwnMessage: createAuthEndpoint("/near/verify", { method: "POST", body: VerifyRequest, requireRequest: true }, async (ctx) => { const { signedMessage, message, recipient, nonce, accountId } = ctx.body; const network = getNetworkFromAccountId(accountId); try { const near = getNear(network); const nonceBytes = hex.decode(nonce); if (!await verifyNep413Signature(signedMessage, { message, recipient, nonce: nonceBytes }, { near, maxAge: 900 * 1e3 })) throw new APIError("UNAUTHORIZED", { message: "Unauthorized: Invalid signature", status: 401 }); if (signedMessage.accountId !== accountId) throw new APIError("UNAUTHORIZED", { message: "Unauthorized: Account ID mismatch", status: 401 }); const publicKey = signedMessage.publicKey; const nonceHash = await hashNonce(nonceBytes); if (await ctx.context.internalAdapter.findVerificationValue(`siwn-nonce:${nonceHash}`)) throw new APIError("UNAUTHORIZED", { message: "Unauthorized: Nonce already used (replay attack detected)", status: 401, code: "UNAUTHORIZED_NONCE_REPLAY" }); await ctx.context.internalAdapter.createVerificationValue({ identifier: `siwn-nonce:${nonceHash}`, value: "used", expiresAt: new Date(Date.now() + 900 * 1e3) }); if (!options.requireFullAccessKey) { if (!await (options.validateLimitedAccessKey || ((args) => defaultValidateLimitedAccessKey(args.accountId, args.publicKey, args.recipient || options.recipient, near)))({ accountId, publicKey, recipient: options.recipient })) throw new APIError("UNAUTHORIZED", { message: "Unauthorized: Invalid function call access key", status: 401 }); } let user = null; const existingNearAccount = await ctx.context.adapter.findOne({ model: "nearAccount", where: [{ field: "accountId", operator: "eq", value: accountId }, { field: "network", operator: "eq", value: network }] }); if (existingNearAccount) user = await ctx.context.adapter.findOne({ model: "user", where: [{ field: "id", operator: "eq", value: existingNearAccount.userId }] }); else { const anyNearAccount = await ctx.context.adapter.findOne({ model: "nearAccount", where: [{ field: "accountId", operator: "eq", value: accountId }] }); if (anyNearAccount) user = await ctx.context.adapter.findOne({ model: "user", where: [{ field: "id", operator: "eq", value: anyNearAccount.userId }] }); } if (!user) { const userEmail = deriveEmail(accountId) ?? ""; const profile = await (options.getProfile || ((id) => defaultGetProfile(id, apiKey)))(accountId); user = await ctx.context.internalAdapter.createUser({ name: profile?.name ?? accountId, email: userEmail, image: profile?.image ? getImageUrl(profile.image) : "" }); await ctx.context.adapter.create({ model: "nearAccount", data: { userId: user.id, accountId, network, publicKey, isPrimary: true, createdAt: /* @__PURE__ */ new Date() } }); await ctx.context.internalAdapter.createAccount({ userId: user.id, providerId: "siwn", accountId: `${accountId}:${network}`, createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date() }); } else if (!existingNearAccount) { await ctx.context.adapter.create({ model: "nearAccount", data: { userId: user.id, accountId, network, publicKey, isPrimary: false, createdAt: /* @__PURE__ */ new Date() } }); await ctx.context.internalAdapter.createAccount({ userId: user.id, providerId: "siwn", accountId: `${accountId}:${network}`, createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date() }); } await ensureRelayer(ctx.context.adapter, ctx.context.secret, network); const session = await ctx.context.internalAdapter.createSession(user.id); if (!session) throw new APIError("INTERNAL_SERVER_ERROR", { message: "Internal Server Error", status: 500 }); await setSessionCookie(ctx, { session, user }); return ctx.json(VerifyResponse.parse({ token: session.token, success: true, user: { id: user.id, accountId, network } })); } catch (error) { if (error instanceof APIError) throw error; const msg = error instanceof Error ? error.message : "Unknown error"; const at = error instanceof Error && error.stack ? error.stack.split("\n").slice(1, 3).map((s) => s.trim()).join(" <- ") : ""; console.error(`[siwn] Verify error: ${msg}${at ? ` (${at})` : ""}`); throw new APIError("UNAUTHORIZED", { message: "Something went wrong. Please try again later.", error: msg, status: 401 }); } }), relayNearTransaction: createAuthEndpoint("/near/relay", { method: "POST", body: RelayRequest, use: [sessionMiddleware] }, async (ctx) => { const { payload } = ctx.body; const session = ctx.context.session; if (!session) throw new APIError("UNAUTHORIZED", { message: "Must be authenticated to relay transactions", status: 401 }); const nearAccount = await ctx.context.adapter.findOne({ model: "nearAccount", where: [{ field: "userId", operator: "eq", value: session.user.id }, { field: "isPrimary", operator: "eq", value: true }] }); if (!nearAccount) throw new APIError("UNAUTHORIZED", { message: "No NEAR account linked to session", status: 401 }); const network = nearAccount.network; const rState = await ensureRelayer(ctx.context.adapter, ctx.context.secret, network); if (!rState) throw new APIError("SERVICE_UNAVAILABLE", { message: "Relayer not configured", status: 503 }); try { const delegateAction = decodeSignedDelegateAction(payload).signedDelegate.delegateAction; if (delegateAction.senderId !== nearAccount.accountId) throw new APIError("UNAUTHORIZED", { message: "Delegate action sender does not match session account", status: 401 }); const relayerConfig = options.relayer; if (relayerConfig?.whitelistedContracts?.length) { if (!relayerConfig.whitelistedContracts.includes(delegateAction.receiverId)) throw new APIError("FORBIDDEN", { message: `Contract ${delegateAction.receiverId} is not whitelisted for relay`, status: 403 }); } if (relayerConfig?.maxGasPerTransaction) { const totalGas = delegateAction.actions.reduce((sum, a) => { return sum + ("functionCall" in a ? a.functionCall.gas : 0n); }, 0n); if (totalGas > BigInt(relayerConfig.maxGasPerTransaction)) throw new APIError("BAD_REQUEST", { message: `Transaction gas (${totalGas}) exceeds relayer limit (${relayerConfig.maxGasPerTransaction})`, status: 400 }); } if (relayerConfig?.maxDepositPerTransaction) { const totalDeposit = delegateAction.actions.reduce((sum, a) => { if ("functionCall" in a) return sum + a.functionCall.deposit; if ("transfer" in a) return sum + a.transfer.deposit; return sum; }, 0n); if (totalDeposit > BigInt(relayerConfig.maxDepositPerTransaction)) throw new APIError("BAD_REQUEST", { message: `Transaction deposit (${totalDeposit}) exceeds relayer limit (${relayerConfig.maxDepositPerTransaction})`, status: 400 }); } const result = await relayOnChain(payload, rState); await ctx.context.adapter.create({ model: "relayedTransaction", data: { userId: session.user.id, txHash: result.txHash, senderId: delegateAction.senderId, receiverId: delegateAction.receiverId, network, status: "pending", createdAt: /* @__PURE__ */ new Date() } }); if (rState.mode === "ephemeral") await ctx.context.adapter.update({ model: "relayerKey", where: [{ field: "network", operator: "eq", value: network }], update: { lastUsedAt: /* @__PURE__ */ new Date() } }); return ctx.json(RelayResponse.parse({ txHash: result.txHash, status: "pending" })); } catch (error) { if (error instanceof APIError) throw error; throw new APIError("INTERNAL_SERVER_ERROR", { message: error instanceof Error ? error.message : "Relay failed", status: 500 }); } }), getRelayStatus: createAuthEndpoint("/near/relay-status/:txHash", { method: "GET", use: [sessionMiddleware] }, async (ctx) => { const txHash = ctx.params?.txHash; if (!txHash) throw new APIError("BAD_REQUEST", { message: "Transaction hash required", status: 400 }); const session = ctx.context.session; const relayedTx = await ctx.context.adapter.findOne({ model: "relayedTransaction", where: [{ field: "txHash", operator: "eq", value: txHash }, { field: "userId", operator: "eq", value: session.user.id }] }); if (!relayedTx) throw new APIError("NOT_FOUND", { message: "Transaction not found or not owned by this user", status: 404 }); const network = relayedTx.network; const senderId = relayedTx.senderId; try { const txResult = await getNear(network).getTransactionStatus(txHash, senderId); const txStatus = txResult.status; if (txStatus && typeof txStatus === "object") { const hasSuccess = "SuccessValue" in txStatus || "SuccessReceiptId" in txStatus; const hasFailure = "Failure" in txStatus; const status = hasSuccess ? "completed" : hasFailure ? "failed" : "pending"; if (status !== "pending") await ctx.context.adapter.update({ model: "relayedTransaction", where: [{ field: "txHash", operator: "eq", value: txHash }], update: { status } }); const gasUsed = txResult.transaction_outcome?.outcome?.gas_burnt?.toString(); return ctx.json(RelayStatusResponse.parse({ status, gasUsed, outcome: txResult })); } return ctx.json(RelayStatusResponse.parse({ status: "pending" })); } catch (error) { return ctx.json(RelayStatusResponse.parse({ status: "pending" })); } }), getRelayerInfo: createAuthEndpoint("/near/relayer-info", { method: "GET", use: [sessionMiddleware] }, async (ctx) => { const session = ctx.context.session; const network = (await ctx.context.adapter.findOne({ model: "nearAccount", where: [{ field: "userId", operator: "eq", value: session.user.id }, { field: "isPrimary", operator: "eq", value: true }] }))?.network || "mainnet"; const rState = await ensureRelayer(ctx.context.adapter, ctx.context.secret, network); if (!rState) return ctx.json({ enabled: false }); const near = getNear(network); let account; try { account = await near.getAccount(rState.accountId); } catch { account = { balance: "0", available: "0", staked: "0", storageUsage: "0", storageBytes: 0, hasContract: false, codeHash: "" }; } return ctx.json({ enabled: true, accountId: rState.accountId, mode: rState.mode, network: rState.network, balance: account.balance, available: account.available, staked: account.staked, storageUsage: account.storageUsage, storageBytes: account.storageBytes, hasContract: account.hasContract, hasKey: true, createdAt: rState.createdAt, lastUsedAt: rState.lastUsedAt }); }), getRelayHistory: createAuthEndpoint("/near/relay-history", { method: "GET", use: [sessionMiddleware] }, async (ctx) => { const session = ctx.context.session; let transactions = []; try { transactions = await ctx.context.adapter.findMany({ model: "relayedTransaction", where: [{ field: "userId", operator: "eq", value: session.user.id }] }) || []; } catch (err) { console.error("relay-history findMany error:", err); } const sorted = transactions.sort((a, b) => { const aTime = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt ?? 0).getTime(); return (b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt ?? 0).getTime()) - aTime; }); return ctx.json({ transactions: sorted.map((tx) => ({ id: String(tx.id ?? ""), userId: String(tx.userId ?? ""), txHash: String(tx.txHash ?? ""), senderId: String(tx.senderId ?? ""), receiverId: String(tx.receiverId ?? ""), network: String(tx.network ?? "mainnet"), status: String(tx.status ?? "pending"), gasUsed: tx.gasUsed ? String(tx.gasUsed) : void 0, createdAt: tx.createdAt instanceof Date ? tx.createdAt.toISOString() : tx.createdAt ? String(tx.createdAt) : (/* @__PURE__ */ new Date()).toISOString(), updatedAt: tx.updatedAt instanceof Date ? tx.updatedAt.toISOString() : tx.updatedAt ? String(tx.updatedAt) : void 0 })) }); }), viewContract: createAuthEndpoint("/near/view", { method: "POST", body: ViewContractRequest, use: [sessionMiddleware] }, async (ctx) => { const { contractId, methodName, args } = ctx.body; const session = ctx.context.session; const result = await getNear((await ctx.context.adapter.findOne({ model: "nearAccount", where: [{ field: "userId", operator: "eq", value: session.user.id }, { field: "isPrimary", operator: "eq", value: true }] }))?.network || "mainnet").view(contractId, methodName, args ?? {}); return ctx.json(ViewContractResponse.parse({ result })); }) } }; }; //#endregion export { LinkAccountRequest, NonceRequest, NonceResponse, ProfileRequest, ProfileResponse, RelayHistoryResponse, RelayRequest, RelayResponse, RelayStatusResponse, RelayedTransactionSchema, SetPrimaryAccountRequest, VerifyRequest, VerifyResponse, ViewContractRequest, ViewContractResponse, profileSchema, siwn, socialImageSchema }; //# sourceMappingURL=index.js.map