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

@gringhost/react

v0.12.10

Published

GrinGhost React components — authentication, wallet balance, budget, and recent activity

Readme

@gringhost/react

Composants React pour intégrer GrinGhost dans une app Next.js — authentification OIDC, wallet de crédits, budget par site et activité récente. Le débit est 100% côté serveur (voir « Facturer une action »).

npm install @gringhost/react

Ce que ça fait

GrinGhost est à la fois le provider OAuth de tes utilisateurs et leur wallet de crédits. Ce package fournit :

  • GrinGhostProvider — gère l'auth, le solde, le budget et l'activité en interne via Supabase
  • GrinGhostButton — bouton autonome (login / menu) — pas de props
  • useGrinGhost — hook : user, credits, loadCredits, budget, loadBudget, activity, loadActivity, appUrl

Le SDK gère l'authentification et l'affichage du solde. La facturation, elle, se fait côté serveur (dans ton backend) — voir « Facturer une action » plus bas.

Modèle de facturation : débit serveur

Le débit ne se fait jamais dans le navigateur. Côté serveur, tu débites avant l'appel IA :

POST /api/site/debit   { user_payment_token, action_id, idempotency_key }
   → GrinGhost vérifie le budget, débite, écrit au ledger
   → si l'IA échoue : POST /api/site/refund { debit_id, idempotency_key }

L'utilisateur fixe un budget par site lors de la connexion ; le site ne peut jamais le dépasser.


Prérequis

  • Next.js (App Router) — testé sur 15+
  • Supabase (auth + session)
  • Un site GrinGhost avec au moins une action dans le catalogue

Setup

1. Dashboard GrinGhost

gringhost.com/dashboard/dev → Mes Sites → Nouveau Site

Récupère :

  • sandbox_api_key / api_key — clé API de ton site
  • client_id / client_secret — credentials OAuth
  • id — UUID du site

Puis Catalogue → Nouvelle action pour chaque action payante. Copie l'UUID généré.

2. Supabase — provider OIDC

Authentication → Sign In / Up → Social Providers → Add provider → Custom OAuth 2.0

| Champ | Valeur | |-------|--------| | Provider name | gringhost | | Issuer URL | https://gringhost.com | | Client ID | <client_id> du dashboard GrinGhost | | Client Secret | <client_secret> du dashboard GrinGhost |

Ajoute les redirect URLs (Authentication → URL Configuration) :

http://localhost:3000/auth/callback     ← dev
https://ton-domaine.com/auth/callback   ← prod

3. Variables d'environnement

# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...

# GrinGhost — server-side uniquement (sauf APP_ID)
GRINGHOST_BASE_URL=https://gringhost.com
GRINGHOST_API_KEY=<sandbox_api_key>       # api_key en prod
GRINGHOST_IS_SANDBOX=true                 # false en prod
NEXT_PUBLIC_GRINGHOST_APP_ID=<id du site>
GRINGHOST_MY_ACTION_ID=<uuid de l'action>

# IA
OPENAI_API_KEY=sk-...

GRINGHOST_API_KEY ne doit jamais être exposé côté client.

4. Clients Supabase

lib/supabase/client.ts — browser :

import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

lib/supabase/server.ts — server components et routes API :

import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) { return cookieStore.get(name)?.value },
        set(name: string, value: string, options: CookieOptions) { try { cookieStore.set({ name, value, ...options }) } catch {} },
        remove(name: string, options: CookieOptions) { try { cookieStore.set({ name, value: '', ...options }) } catch {} },
      },
    }
  )
}

5. Callback OAuth — app/auth/callback/route.ts

Reçoit le code après connexion GrinGhost et l'échange contre une session Supabase :

import { createClient } from '@/lib/supabase/server'
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  const { searchParams, origin } = new URL(request.url)
  const code = searchParams.get('code')
  if (code) {
    const supabase = await createClient()
    await supabase.auth.exchangeCodeForSession(code)
  }
  return NextResponse.redirect(origin + '/')
}

6. Rafraîchissement de session — proxy.ts

Maintient la session Supabase active sur chaque requête :

import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function proxy(request: NextRequest) {
  let response = NextResponse.next({ request: { headers: request.headers } })
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) { return request.cookies.get(name)?.value },
        set(name: string, value: string, options: CookieOptions) {
          request.cookies.set({ name, value, ...options })
          response = NextResponse.next({ request: { headers: request.headers } })
          response.cookies.set({ name, value, ...options })
        },
        remove(name: string, options: CookieOptions) {
          request.cookies.set({ name, value: '', ...options })
          response = NextResponse.next({ request: { headers: request.headers } })
          response.cookies.set({ name, value: '', ...options })
        },
      },
    }
  )
  await supabase.auth.getUser()
  return response
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}

7. Provider — app/providers.tsx

layout.tsx est un Server Component — il ne peut pas créer le client Supabase. Isole ça dans un client boundary :

'use client'
import { GrinGhostProvider } from '@gringhost/react'
import { createClient } from '@/lib/supabase/client'

const supabase = createClient()

export default function Providers({ children }: { children: React.ReactNode }) {
  return <GrinGhostProvider supabase={supabase} locale="en">{children}</GrinGhostProvider>
}
// app/layout.tsx — reste Server Component
import Providers from './providers'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

Le bouton GrinGhost

Place <GrinGhostButton /> une seule fois dans ton header — pas de props. Il gère tout :

  • Non connecté → bouton "Continue with GrinGhost"
  • Connecté → menu avec : email + solde du wallet, budget alloué à ce site (barre + « Modifier le budget → »), activité récente (3 derniers débits/remboursements + « Voir tout → »), « Tarifs & avis de cette app », « Gérer sur gringhost.com », déconnexion

Le débit est côté serveur — le bouton ne fait que consulter et rediriger, il ne débite jamais.

import { GrinGhostButton } from '@gringhost/react'

export default function Header() {
  return (
    <header>
      <GrinGhostButton />
    </header>
  )
}

Lire l'état auth et le solde

'use client'
import { useGrinGhost } from '@gringhost/react'

export default function MyComponent() {
  const { user, isLoaded, credits, loadCredits } = useGrinGhost()

  if (!isLoaded) return null
  if (!user) return <p>Connecte-toi via le bouton GrinGhost.</p>

  return <p>{credits} crédits</p>
}

Recharger & gérer le budget (sécurité)

Le menu de GrinGhostButton propose un lien « Modifier le budget → » toujours présent (ouvre gringhost.com/dashboard/apps ; en alerte, un statut rouge l'accompagne) et « Gérer sur gringhost.com » (ouvre le dashboard). La recharge du wallet se fait depuis le dashboard — pas de bouton « Recharger » renvoyant directement vers /buy, par souci de confiance.

Ces actions sont de simples redirections, par conception. Le SDK tourne dans la page du site partenaire : toute action critique (paiement, modification de budget) se fait sur gringhost.com, sous la session first-party de l'utilisateur — jamais via un appel issu du SDK, qui pourrait être falsifié. Le SDK ne fait que consulter et rediriger ; il ne peut ni acheter de crédits ni changer un budget. Le débit reste côté backend partenaire (clé API), plafonné par le budget fixé par l'user.

Depuis 0.12.1, ces redirections emportent ?login_hint=<email de l'user connecté ici>. Si la session gringhost.com porte un autre compte (l'user a deux comptes ouverts), gringhost.com propose de basculer plutôt que d'ouvrir le mauvais wallet. Le hint est non-fiable (avertissement + présélection du compte Google uniquement) — jamais un octroi d'accès, l'auth reste le cookie de session gringhost.com.


Activité récente (activity / loadActivity)

useGrinGhost() expose activity : les derniers débits/remboursements de l'utilisateur sur ton site (jamais son activité sur d'autres sites). Le menu de GrinGhostButton affiche déjà les 3 plus récents avec un lien « Voir tout → » vers gringhost.com/dashboard/activity. Tu peux aussi l'afficher toi-même :

'use client'
import { useEffect } from 'react'
import { useGrinGhost } from '@gringhost/react'

export function RecentActivity() {
  const { activity, loadActivity } = useGrinGhost()
  useEffect(() => { loadActivity() }, [loadActivity])
  if (!activity?.length) return null
  return (
    <ul>
      {activity.map(a => (
        <li key={a.id}>{a.label} · {a.type === 'refund' ? '+' : '−'}{Math.abs(a.credits)} cr</li>
      ))}
    </ul>
  )
}

Chaque entrée : { id, type: 'debit' | 'refund', label, credits, created_at }.

Prérequis : une route proxy côté ton app, app/api/internal/activity/route.ts, qui relaie vers GET {GRINGHOST_BASE_URL}/api/user/activity avec ton GRINGHOST_API_KEY + le provider_token (même schéma que /api/internal/budget). Le starter l'inclut déjà.


Fiche de l'app — tarifs & avis (appUrl)

useGrinGhost() expose appUrl : l'URL publique de la fiche de ton app sur GrinGhost (https://gringhost.com/catalog/...), qui affiche description, tarifs (catalogue d'actions), note moyenne ★ et tous les avis utilisateurs. Le bouton GrinGhostButton propose déjà l'entrée « Tarifs & avis de cette app » dans son menu — mais tu peux aussi poser ton propre lien :

'use client'
import { useGrinGhost } from '@gringhost/react'

export function PricingLink() {
  const { appUrl } = useGrinGhost()
  if (!appUrl) return null
  return <a href={appUrl} target="_blank" rel="noopener noreferrer">Tarifs & avis</a>
}

appUrl est dérivée automatiquement du client_id présent dans le token (donc dispo dès que l'utilisateur est connecté). Pour l'avoir avant connexion, passe ton siteId au provider :

<GrinGhostProvider supabase={supabase} siteId="ton-site-id">{children}</GrinGhostProvider>

Depuis la fiche, un utilisateur qui a réellement utilisé l'app peut la noter (lien « Donne ton avis » vers son dashboard GrinGhost).


Route credits — app/api/internal/credits/route.ts

Lit le solde GrinGhost de l'utilisateur connecté. Appelée automatiquement par GrinGhostProvider.

⚠️ Durabilité — le provider_token expire ~1h après le login (Supabase le supprime de la session). L'exemple ci-dessous est la version « live only » : passé cette heure, provider_token est absent et le solde retombe à 0. En production, stocke le payment_token (ghpay_xxx) dès que tu l'obtiens (il n'expire pas) et lis le solde en secours via GET /api/user/budget avec x-api-key + x-payment-token. Le starter Next.js le fait déjà (lib/gringhost.ts, cookie httpOnly) — recommandé pour toute app réelle.

import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'

export async function GET() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })

  const { data: { session } } = await supabase.auth.getSession()
  const providerToken = session?.provider_token
  if (!providerToken) return NextResponse.json({ credits: 0 })

  try {
    const res = await fetch(`${process.env.GRINGHOST_BASE_URL}/api/oauth/userinfo`, {
      headers: { 'Authorization': `Bearer ${providerToken}` },
    })
    if (!res.ok) return NextResponse.json({ credits: 0 })
    const data = await res.json()
    const isSandbox = process.env.GRINGHOST_IS_SANDBOX === 'true'
    return NextResponse.json({ credits: isSandbox ? (data.sandbox_credits ?? 0) : (data.credits ?? 0) })
  } catch {
    return NextResponse.json({ credits: 0 })
  }
}

Facturer une action (côté serveur)

La facturation se fait dans ton backend, jamais dans le navigateur. Récupère d'abord le user_payment_token (ghpay_xxx) de l'utilisateur via GET /api/user/payment-auth (authentifié par son provider_token + ta clé API), puis débite avant l'appel IA.

⚠️ Durabilité : le provider_token expire ~1h après le login. L'exemple ci-dessous re-fetch payment-auth à chaque appel → au-delà d'une heure il renvoie payment_not_configured (402). En production, stocke le payment_token (ghpay_xxx) dès qu'il est disponible et réutilise-le (il n'expire pas). Le starter fournit getPaymentToken() (lib/gringhost.ts, cookie httpOnly) qui gère ce fallback pour toi.

Route action — app/api/internal/mon-action/route.ts

import { createClient } from '@/lib/supabase/server'
import { NextRequest, NextResponse } from 'next/server'

const GH = process.env.GRINGHOST_BASE_URL
const KEY = { 'Content-Type': 'application/json', 'x-api-key': process.env.GRINGHOST_API_KEY! }

export async function POST(request: NextRequest) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
  const { input } = await request.json()

  // 1. Récupérer le ghpay_xxx de l'utilisateur
  const { data: { session } } = await supabase.auth.getSession()
  const providerToken = session?.provider_token
  if (!providerToken) return NextResponse.json({ error: 'payment_not_configured' }, { status: 402 })
  const payRes = await fetch(`${GH}/api/user/payment-auth`, {
    headers: { 'Authorization': `Bearer ${providerToken}`, 'x-api-key': process.env.GRINGHOST_API_KEY! },
  })
  if (!payRes.ok) return NextResponse.json({ error: 'payment_not_configured' }, { status: 402 })
  const { payment_token } = await payRes.json()

  // 2. Débiter AVANT l'appel IA
  const debitRes = await fetch(`${GH}/api/site/debit`, {
    method: 'POST', headers: KEY,
    body: JSON.stringify({ user_payment_token: payment_token, action_id: process.env.GRINGHOST_MY_ACTION_ID, idempotency_key: crypto.randomUUID() }),
  })
  if (debitRes.status === 402) return NextResponse.json({ error: 'budget_exceeded' }, { status: 402 })
  if (!debitRes.ok)            return NextResponse.json({ error: 'debit_failed' }, { status: 500 })
  const { debit_id } = await debitRes.json()

  // 3. Appel IA (après débit confirmé). Si l'IA échoue : POST /api/site/refund { debit_id }
  const result = await callYourAI(input)
  return NextResponse.json({ result })
}

Côté client

'use client'
import { useGrinGhost } from '@gringhost/react'

export function MyComponent() {
  const { loadCredits } = useGrinGhost()

  async function handleAction() {
    const res = await fetch('/api/internal/mon-action', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ input: '...' }),
    })
    if (res.status === 402) { window.open('https://gringhost.com/dashboard/apps', '_blank'); return }  // budget épuisé → l'user l'ajuste sur son dashboard
    loadCredits()  // rafraîchit le solde dans GrinGhostButton
  }

  return <button onClick={handleAction}>Lancer l'action</button>
}

Flux de facturation — détail

[Utilisateur clique]
      ↓
POST /api/internal/mon-action           (ton serveur)
      ↓
POST /api/site/debit                    (GrinGhost) → { debit_id }   ← débite (budget vérifié)
      ↓
Appel IA   (si elle échoue : POST /api/site/refund { debit_id })
      ↓
Réponse

Sandbox vs Production

| | Sandbox (sandbox_api_key) | Production (api_key) | |---|---|---| | Wallet réel touché | Non, jamais | Oui | | Crédits débités | Aucun (fictif) | Crédits réels | | Entrée ledger | Oui (is_sandbox: true) | Oui | | GRINGHOST_IS_SANDBOX | true | false |

En prod : seuls GRINGHOST_API_KEY et GRINGHOST_IS_SANDBOX changent. Aucune modification de code.


Garanties de sécurité

Budget plafonné par l'utilisateur

L'utilisateur fixe un budget par site lors de la connexion (max_budget_credits). Le site ne peut jamais le dépasser, ni voir le solde total. Le prix vient du catalogue — le dev ne peut pas le gonfler au moment du débit (changements loggés et surveillés). Autorisation révocable à tout moment.

Remboursement si l'IA échoue

Si l'IA plante après un débit, le serveur appelle POST /api/site/refund { debit_id } → les crédits sont rendus à l'utilisateur.

Deux secrets requis + anti-double-débit

  • Débiter exige le user_payment_token (ghpay_xxx) et la clé API du site. Un seul ne suffit pas.
  • Le token est lié à un (user_id, site_id) — inutilisable sur un autre site.
  • idempotency_key UNIQUE — un retry réseau avec la même clé ne débite jamais deux fois.
  • Génère toujours crypto.randomUUID() frais par requête.

Tarification

1 crédit = 0.001 $ pour l'utilisateur. Tu (développeur) reçois 90%.

const coutReel = 0.00005  // $ — coût API IA
const marge    = 2
const credits  = Math.ceil(coutReel * marge / 0.001)  // nombre de crédits

Les prix sont dans le catalogue GrinGhost, pas dans le code. Le dev les fait évoluer librement — chaque changement est enregistré et surveillé par GrinGhost. La garantie utilisateur reste son budget, infranchissable.


Documentation complète

gringhost.com/docs/dev

Langue du widget

GrinGhostProvider accepte locale?: 'en' | 'fr' (défaut 'en') : toutes les chaînes du bouton et de son menu (Account/Compte, budget, activité, Sign out/Se déconnecter…) et les formats de nombres suivent cette prop. Passe la langue courante de ton app :

<GrinGhostProvider supabase={supabase} locale={locale}>