npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@stacknet/userutils

v0.8.2

Published

Reusable auth, billing, and security utilities for StackNet stacks and applications

Readme

@stacknet/userutils

Authentication, session management, and billing for StackNet apps.

Retained subscription Checkout

useSubscription(apiBaseUrl, {authKey, successUrl, cancelUrl}) keeps its existing subscription/cancellation views and delegates new subscriptions to the canonical Checkout intent engine. Pass a public session identity key that changes on sign-in transitions; it only invalidates displayed state. The existing authenticated subscription read supplies the canonical customer and selected stack. Neither the key nor an owner header authenticates the payer. The BFF compares the retained expected owner to the current verified original credential before forwarding.

The returned checkout exposes canStart, pending, current, offers, check(), resume(), and archive(). Display recovery controls whenever a pending request exists. Reload only reads current and exact saved status. An explicit resume uses the same body/UUID; it never creates a replacement after unknown/404. An exact funded/expired result can be archived only after a fresh canonical current read is empty. Rejected/unavailable is held, not discarded.

For apps with another verified scope source, use useCustomerCheckout with {scope, endpoint, successUrl, cancelUrl}. The default IndexedDB store commits the full selected offer/hash/return URLs/UUID before POST and atomically claims it across tabs. Storage failure prevents submission. Scope changes never move drafts. Return URLs are retained unchanged and checked by Rust's origin binding; the server no longer derives or replaces them from Host or canonicalOrigin.

Mount the existing createBillingProxy Checkout handlers at these routes:

| App route | Factory handler | |---|---| | GET /api/billing/checkout/offers | checkout.offers.GET | | GET /api/billing/checkout/intents/current | checkout.current.GET | | POST /api/billing/checkout/intents | checkout.intents.POST | | GET /api/billing/checkout/intents/:id | checkout.intent.GET(request, id) | | POST /api/billing/checkout/intents/:id/resume | checkout.resume.POST(request, id) |

The existing subscribe.POST also accepts the complete retained intent. A plan-only request is rejected. Existing signature verification, CSRF and user rate limiting remain. Only explicit user action may navigate an exact open result's Checkout URL. All customer funds remain stack subscription credits. Actual browser IndexedDB and paired authority/deployment acceptance remain release checks; synthetic source tests do not verify a processor payment.

Canonical network profile updates

The profile intent helpers retain a complete update in IndexedDB before sending it: the original mutation UUID, expected canonical revision, username, avatar and bio. The draft is scoped to the service endpoint, authenticated owner and explicit subscription stack. Storage failure prevents the request; a lost or unverifiable response keeps the original draft for exact action recovery.

Geoff's profile proxy forwards the original customer credential and configured stack. A successful update requires the canonical profile and the existing 10-million-token fee receipt to match that mutation. Public reads retain the legacy profile fields and add canonical revision metadata without duplicating avatar content. Supported data images are PNG, JPEG, WebP, GIF and AVIF, up to 5 MiB decoded; HTTP(S) image URLs are also supported.

These helpers and proxy are a source integration slice. The complete settings and SDK callers and real browser IndexedDB validation must be paired before release. ProfileRevisionConflict retains a submitted update. After the user chooses to review a replacement, retireRejectedProfileIntent performs a fresh exact-action lookup and requires the authority-verified immutable no-effect outcome to match the full saved request fingerprint and scope before clearing that UUID. The caller then loads the latest profile and lets the user review a new save. A generic 409, 404 or timeout cannot retire an update. A returned public key is not a new trust root; Rust verifies its configured authority signer.

Google One Tap, OAuth (Google, Discord, Telegram, X), wallet login (Solana, Ethereum), cross-domain SSO via auth bridge, CSRF protection, and billing/subscription hooks.

Install

pnpm add @stacknet/userutils

Quick Start

1. Set up environment variables

# .env.local
AUTH_SECRET=your-hmac-secret-min-32-chars
NEXT_PUBLIC_STACK_ID=stk_your_stack_id
NEXT_PUBLIC_STACKNET_URL=https://stacknet.magma-rpc.com

2. Create API routes

Three server-side routes are required. These handle JWT signing, session validation, and logout — keeping secrets off the client.

// app/api/auth/callback/route.ts
import { createAuthCallback } from '@stacknet/userutils/server'

const handler = createAuthCallback({
  authSecret: process.env.AUTH_SECRET!,
  stacknetUrl: process.env.NEXT_PUBLIC_STACKNET_URL || 'https://stacknet.magma-rpc.com',
  stackId: process.env.NEXT_PUBLIC_STACK_ID!,
  secureCookies: process.env.NODE_ENV === 'production',
})

export async function POST(request: Request) {
  return handler(request)
}
// app/api/auth/session/route.ts
import { createSessionHandler } from '@stacknet/userutils/server'

const handler = createSessionHandler({
  authSecret: process.env.AUTH_SECRET!,
  secureCookies: process.env.NODE_ENV === 'production',
})

export async function GET(request: Request) {
  return handler(request)
}
// app/api/auth/logout/route.ts
import { createLogoutHandler } from '@stacknet/userutils/server'

const handler = createLogoutHandler({
  stacknetUrl: process.env.NEXT_PUBLIC_STACKNET_URL || 'https://stacknet.magma-rpc.com',
  secureCookies: process.env.NODE_ENV === 'production',
})

export async function POST(request: Request) {
  return handler(request)
}

3. Wrap your app with the provider

// components/auth-provider.tsx
'use client'

import { UserUtilsProvider } from '@stacknet/userutils/components'

const config = {
  apiBaseUrl: '',  // same-origin — hooks call your /api/auth/* routes
  stackId: process.env.NEXT_PUBLIC_STACK_ID,
  stacknetUrl: process.env.NEXT_PUBLIC_STACKNET_URL,
  theme: 'dark',
}

export function AuthProvider({ children }) {
  return (
    <UserUtilsProvider config={config}>
      {children}
    </UserUtilsProvider>
  )
}
// app/layout.tsx
import { AuthProvider } from '../components/auth-provider'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <AuthProvider>{children}</AuthProvider>
      </body>
    </html>
  )
}

4. Add a connect page

// app/connect/page.tsx
'use client'

import { ConnectWidget } from '@stacknet/userutils/components'

export default function ConnectPage() {
  return (
    <ConnectWidget
      config={{
        apiBaseUrl: '',
        stackId: process.env.NEXT_PUBLIC_STACK_ID,
        stacknetUrl: process.env.NEXT_PUBLIC_STACKNET_URL,
      }}
      onSuccess={() => { window.location.href = '/' }}
      showWallets={['phantom', 'metamask']}
      showOTP={false}
    />
  )
}

5. Show auth state in your header

// components/header.tsx
'use client'

import { useSession, useStackAuth } from '@stacknet/userutils/hooks'
import Link from 'next/link'

export function Header() {
  const { isAuthenticated, loading } = useSession()
  const { session, logout, wallet } = useStackAuth()

  const displayAddress = wallet.address
    ? `${wallet.address.slice(0, 4)}...${wallet.address.slice(-4)}`
    : session?.userId?.slice(0, 8) ?? ''

  if (loading) return <div>Loading...</div>

  if (isAuthenticated) {
    return (
      <div>
        <span>{displayAddress}</span>
        <button onClick={logout}>Logout</button>
      </div>
    )
  }

  return <Link href="/connect">Log in</Link>
}

The JWT auto-refreshes when /api/auth/session is called and the token is within 5 minutes of expiry.

Telegram sign-in

Telegram is one more network sign-in method, a peer of Phantom, MetaMask and Google. The stack's configuration only picks which bot runs the Telegram UI. StackNet verifies the Telegram data with that bot's token, derives the network globalId from the Telegram user id, and mints the same network session as the Google path. The resulting identity is network-wide. This package runs the Telegram UI and forwards to StackNet. It never holds a bot token and never checks the Telegram HMAC itself.

1. Stack-level configuration (on StackNet)

Add a telegram entry to the stack's oauthProviders:

| Field | Value | | --- | --- | | provider | "telegram" | | clientId | the bot's username, e.g. my_app_bot (public) | | clientSecretKey | the stack secret that holds the bot token from @BotFather (never sent to the browser or this app) | | enabled | true |

Visibility is decided by StackNet. userutils reads the stack's public view (GET /api/v2/stacks/{stackId}, the same endpoint useStackConfig uses) and shows Telegram only when oauthProviders has an entry with provider: "telegram" and enabled: true. A stack can still hide it with features.oauthAuth: false. App config and env never decide it, and initiate is never called just to probe. The config is cached in memory for a minute and shared by every sign-in surface on the page. While it loads, the Telegram option is not rendered. If the fetch fails, Telegram stays hidden (fail closed) and the other methods keep their existing fallback. If initiate later answers NOT_CONFIGURED (the stack config changed), the option is removed and a short notice is shown.

const { providers, loading, isEnabled } = useStackSignInMethods({ stackId })
isEnabled('telegram') // also 'google', 'apple', 'discord', 'twitter', 'phantom', 'metamask', 'otp'

2. BotFather /setdomain

In @BotFather, run /setdomain for the bot and set it to the app's domain (e.g. geoff.ai). Telegram refuses to authenticate on any other origin.

3. Server routes (Next.js App Router)

// lib/telegram-login.ts
import { createTelegramLoginHandler } from '@stacknet/userutils/server'

export const telegramLogin = createTelegramLoginHandler({
  authSecret: process.env.AUTH_SECRET!,
  stacknetUrl: process.env.NEXT_PUBLIC_STACKNET_URL || 'https://stacknet.magma-rpc.com',
  stackId: process.env.NEXT_PUBLIC_STACK_ID!,   // fixed server-side; clients cannot pick a stack
  serviceKey: process.env.STACKNET_STACK_SERVICE_KEY, // optional stack-scoped key
  secureCookies: process.env.NODE_ENV === 'production',
})
// app/api/auth/telegram/start/route.ts
import { telegramLogin } from '@/lib/telegram-login'
export const POST = (request: Request) => telegramLogin.start(request)
// app/api/auth/telegram/complete/route.ts
import { telegramLogin } from '@/lib/telegram-login'
export const POST = (request: Request) => telegramLogin.complete(request)
  • start calls StackNet POST /api/v2/stacks/{stackId}/auth/oauth/telegram/initiate and returns { botUsername, botId?, state }. It also sets a 10-minute HttpOnly stackauth_tg_state cookie that binds the state to this browser.
  • complete takes { authData, state }. It requires the state cookie to match, accepts only the documented Telegram fields (id, first_name, last_name, username, photo_url, auth_date, hash) with bounded sizes and a body of at most 4 KiB, and rejects auth_date older than 15 minutes (StackNet's verify window). It forwards the Telegram object unchanged to StackNet .../auth/oauth/telegram/verify and sets the same cookies as the OAuth callback (stackauth_jwt, stackauth_session, CSRF). The session's authMethod is oauth:telegram. Its address is the numeric Telegram id, so show a name from your profile data rather than the address.
  • Each attempt uses a fresh StackNet state (single use, 10-minute TTL).
  • Both are rate limited per IP (10/min by default; pass { rateLimiter }).
  • Errors are { error, code } with code one of rate_limited, invalid_request, invalid_auth_data, expired, invalid_state, not_configured, verification_failed, upstream_error.

4. Client

ConnectWidget lists Telegram automatically when the stack config has an enabled telegram provider, with the same button style as the other methods. Pass telegramRequestWriteAccess to ask for permission for the bot to message the user (off by default).

On its own:

import { TelegramLoginButton } from '@stacknet/userutils/components'

<TelegramLoginButton                       // renders null unless the stack enables telegram
  stackId={process.env.NEXT_PUBLIC_STACK_ID}
  onSuccess={() => { window.location.href = '/' }}
  // requestWriteAccess   // default false
  // unavailable="disabled" // default "hide": renders nothing without stack config
/>

Or the hook (useTelegramLogin), or useStackAuth().authenticateTelegram() (also reached by authenticateOAuth('telegram')). Failures are TelegramLoginError with code: not_configured, script_load_failed, cancelled, popup_blocked, container_required, unsupported_environment, rate_limited, start_failed, verify_failed. A user cancel does not set the hook's error. useStackAuth records the sign-in on the auth bridge (chain: 'telegram', address = Telegram id), like the wallet methods.

Popup or widget. When StackNet returns the numeric botId, the click opens Telegram's popup (Telegram.Login.auth). The start data and script are fetched on intent (pointer enter or focus), never on mount, so the popup opens within the click gesture. Each attempt uses a fresh state. Without botId, the client falls back to Telegram's embedded widget button (data-telegram-login). TelegramLoginButton and ConnectWidget render it in place. With the hook, pass a container element to login(container).

5. Content-Security-Policy

script-src  'self' https://telegram.org;
frame-src   https://oauth.telegram.org;
connect-src 'self' https://oauth.telegram.org;
img-src     'self' https://t.me https://*.telegram.org;   # only if you show photo_url

The popup flow needs no inline script and no unsafe-eval. The widget fallback does: Telegram's script evaluates the data-onauth attribute, so it needs 'unsafe-eval' in script-src. Prefer the popup (StackNet supplies botId) under a strict CSP.

The popup talks to its opener with postMessage, so do not send Cross-Origin-Opener-Policy: same-origin on the sign-in page. Use same-origin-allow-popups or leave COOP unset.

Wait list (allowlist stacks)

When a stack admits users from an allowlist, a refused sign-in is recorded on StackNet as an access request and the user sees one neutral notice, AUTH_WAITLIST_MESSAGE ("You've been added to the wait list"; the older TELEGRAM_WAITLIST_MESSAGE export is an alias). ConnectWidget renders it as data-testid="connect-waitlisted" for every provider.

  • Browser OAuth redirect. StackNet sends the browser back to the return URL the flow started with (the redirectUri given to /api/auth/oauth/start, by default /api/auth/oauth/callback) with auth_error=waitlisted|cancelled|failed&provider=<p>. Add a GET handler there that forwards to your sign-in page:

    // app/api/auth/oauth/callback/route.ts
    const { handleCallback, handleReturn } = createOAuthHandlers(serverConfig)
    export const POST = handleCallback
    export const GET = (request: Request) => handleReturn(request, { signInPath: '/connect' })

    On that page useStackAuth reads the params once (oauthReturn), sets error to the wait-list notice (or OAUTH_FAILED_MESSAGE; a cancel shows nothing), and removes them with history.replaceState, so a reload does not show it again. ConnectWidget opens on the notice; Back dismisses it (dismissOAuthReturn).

  • JSON callback. handleCallback / exchange map StackNet's 403 AUTH_DENIED to 403 { error: AUTH_WAITLIST_MESSAGE, code: 'waitlisted' }, and authenticateOAuthCallback shows the same notice.

  • Telegram. waitlisted error code, same notice.

  • Wallets (Phantom/Solflare, MetaMask). StackNet refuses a wallet that is not allowed at the challenge (403 AUTH_DENIED) and again after the signature. Either refusal shows the same notice: createAuthCallback maps the verify-time 403 to 403 { error: AUTH_WAITLIST_MESSAGE, code: 'waitlisted' }, and the core client's getChallenge / loginWeb3 throw an error with that message and code: 'waitlisted'. Other challenge failures show a neutral message (walletChallengeErrorMessage); isWalletAuthRefusal tells the two apart.

Hooks

useSession()

Reads the public session cookie. No network calls.

const { session, loading, isAuthenticated, refresh, readSession } = useSession()

// session: { userId, address, chain, expiresAt, planId, authMethod } | null
// isAuthenticated: boolean (session exists and not expired)

useStackAuth(config?)

Full auth engine — wallet connection, challenge/sign/verify, logout, bridge.

const {
  session,              // PublicSession | null
  isAuthenticated,      // boolean
  wallet,               // { connected, address, chain, provider }
  loading,              // boolean (auth in progress)
  error,                // string | null
  authenticateSolana,   // (provider?: 'phantom' | 'solflare') => Promise<boolean>
  authenticateEVM,      // () => Promise<boolean>
  authenticateOTP,      // (code: string) => Promise<boolean>
  authenticateTelegram, // (opts?: { requestWriteAccess?, container? }) => Promise<boolean>
  logout,               // () => Promise<void>
  refresh,              // () => Promise<any>
  stackId,              // effective stack ID
  bridge,               // { ready, known, identity, identityCount, resolvedStackId }
} = useStackAuth({
  apiBaseUrl: '',
  stackId: 'stk_...',
  stacknetUrl: 'https://stacknet.magma-rpc.com',
  autoConnect: false,   // true = auto-login if bridge has known identity
})

Components

ConnectWidget

Drop-in wallet connect UI.

<ConnectWidget
  config={config}           // UserUtilsConfig (required)
  onSuccess={() => {}}      // Called after successful auth
  title="Connect"           // Heading text
  showWallets={['phantom', 'metamask']}  // Which wallets to show
  showOTP={false}           // Show access code option
  telegramRequestWriteAccess={false} // Telegram: ask to let the bot message the user
  className=""              // CSS classes
/>

UserUtilsProvider

React context provider. Wrap your app with this.

<UserUtilsProvider config={config} callbacks={callbacks?}>
  {children}
</UserUtilsProvider>

Security

  • HttpOnly cookies prevent XSS token theft
  • SameSite=Lax prevents CSRF on state-changing requests
  • Secure flag enforces HTTPS in production
  • CSRF double-submit validates cookie matches header on mutations
  • Short JWT expiry (15 min) with transparent auto-refresh
  • Rate limiting on auth callback (10 req/min per IP)
  • Constant-time comparison for JWT signature validation

License

MIT