better-near-auth
Version:
Sign in with NEAR (SIWN) plugin for Better Auth
1,269 lines (1,268 loc) • 39.6 kB
JavaScript
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