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.9.2

Published

GrinGhost React components — authentication, credits, and confirmation flow

Readme

@gringhost/react

Composants React pour intégrer GrinGhost dans une app Next.js — authentification OIDC, wallet de crédits, et confirmation de paiement inviolable.

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 et les crédits en interne via Supabase
  • GrinGhostButton — bouton autonome (login / menu / confirmation) — pas de props
  • useGrinGhost — hook pour lire user, credits, loadCredits
  • useGrinGhostAction — hook pour déclencher une action payante avec confirmation

Garantie de prix inviolable

Le prix affiché à l'utilisateur avant chaque débit est vérifié directement depuis GrinGhost, sans passer par le serveur du développeur.

Flux dans useGrinGhostAction.prepare() :

1. Appelle le serveur du dev → reçoit { token }
   ↑ token en état "pending" — refusé par /api/site/debit

2. Appelle directement www.gringhost.com/api/public/session-token-info?token=<token>
   ← reçoit { credits_cost, action_name } depuis la DB GrinGhost
   ↑ impossible à falsifier — GrinGhost contrôle cette réponse

3. GrinGhostButton affiche le prix vérifié par GrinGhost

4. Si l'utilisateur confirme → appelle www.gringhost.com/api/user/authorize
   credentials: "include" ← cookie de session GrinGhost (pas de clé API)
   ← { ok: true } → token passe en état "authorized"
   ↑ seul le navigateur peut faire cet appel — serveur du dev impossible

5. prepare() retourne { sessionToken } → le dev peut maintenant appeler /api/site/debit

Prérequis

  • Next.js 14+ (App Router)
  • Supabase (auth + session)
  • Un site GrinGhost avec au moins une action dans le catalogue

Setup

1. Dashboard GrinGhost

gringhost.com/dashboard/devMes SitesNouveau 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}>{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, acheter des crédits, déconnexion
  • Action en attente → panneau de confirmation avec prix vérifié + countdown 28s
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>
}

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

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

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 })
  }
}

Ajouter une action payante

Chaque action payante = une route prepare + une route execute.

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

Génère un session token GrinGhost (prix verrouillé, TTL 30s).

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

export async function POST() {
  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({ error: 'no_gringhost_token' }, { status: 403 })

  const res = await fetch(`${process.env.GRINGHOST_BASE_URL}/api/site/session-token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': process.env.GRINGHOST_API_KEY!,
    },
    body: JSON.stringify({
      action_id:         process.env.GRINGHOST_MY_ACTION_ID,
      user_access_token: providerToken,
    }),
  })
  const data = await res.json()

  if (res.status === 402) return NextResponse.json({ error: 'insufficient_credits' }, { status: 402 })
  if (res.status === 401) return NextResponse.json({ error: 'session_expired' },       { status: 401 })
  if (!res.ok)            return NextResponse.json({ error: 'session_token_failed' },  { status: 500 })

  return NextResponse.json({ token: data.token, credits_cost: data.credits_cost, action_name: data.action_name })
}

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

Reçoit le session_token confirmé par l'utilisateur, débite, appelle l'IA.

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

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, session_token } = await request.json()

  const debitRes = await fetch(`${process.env.GRINGHOST_BASE_URL}/api/site/debit`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': process.env.GRINGHOST_API_KEY!,
    },
    body: JSON.stringify({
      session_token,
      idempotency_key: crypto.randomUUID(),
    }),
  })
  if (debitRes.status === 402) return NextResponse.json({ error: 'insufficient_credits' }, { status: 402 })
  if (!debitRes.ok)            return NextResponse.json({ error: 'debit_failed' },          { status: 500 })

  // Appel IA ici
  const result = '...'

  return NextResponse.json({ result })
}

Côté client

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

export function MyComponent() {
  const { loadCredits } = useGrinGhost()
  const { prepare } = useGrinGhostAction('/api/internal/mon-action/prepare')

  async function handleAction() {
    const result = await prepare()  // affiche la confirmation dans GrinGhostButton
    if (!result) return             // annulé ou timeout 28s

    const res = await fetch('/api/internal/mon-action', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ input: '...', session_token: result.sessionToken }),
    })

    if (res.status === 402) {
      window.open('https://gringhost.com/buy', '_blank')
      return
    }

    loadCredits()  // rafraîchit le solde dans GrinGhostButton
  }

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

Flux de confirmation — détail

[Utilisateur clique]
      ↓
useGrinGhostAction.prepare()
      ↓
POST /api/internal/mon-action/prepare   (serveur du dev)
      ↓
POST /api/site/session-token            (GrinGhost)
      ↓ { token }  ← status = 'pending'
      |
      | ← le dev peut retourner credits_cost: 1 ici (mensonge ignoré)
      ↓
GET /api/public/session-token-info?token=...   (GrinGhost, direct depuis le browser)
      ↓ { credits_cost: X, action_name }        ← prix vérifié depuis la DB
      ↓
GrinGhostButton s'ouvre automatiquement
  ┌─────────────────────────────┐
  │  Confirmation               │
  │  Mon Action   X crédits     │  ← prix GrinGhost, pas celui du dev
  │  [Confirmer]   [Annuler]    │
  │  ████████░░░░  18s          │
  └─────────────────────────────┘
      ↓ (si Confirmer)
POST /api/user/authorize  credentials:"include"  (GrinGhost, cookie session)
      ↓ { ok: true }  ← status = 'authorized'
      ↓ (seul le navigateur peut faire cet appel)
prepare() retourne { sessionToken }
      ↓
POST /api/internal/mon-action           (serveur du dev)
      ↓
POST /api/site/debit                    (GrinGhost — accepté car 'authorized')
      ↓
Appel IA
      ↓
Réponse

Sandbox vs Production

| | Sandbox (sandbox_api_key) | Production (api_key) | |---|---|---| | Crédits débités | Aucun | Crédits réels | | Session token requis | Oui | Oui | | Entrée ledger | Oui (0 valeurs) | Oui (vraies valeurs) | | 402 si solde vide | Non | 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é

Débit impossible sans confirmation utilisateur

Chaque session_token démarre en état pending. /api/site/debit retourne 403 sur un token pending. Pour passer à authorized, il faut appeler POST /api/user/authorize avec le cookie de session GrinGhost de l'utilisateur (credentials: "include"). Un serveur de développeur ne possède jamais ce cookie — seul le navigateur peut faire cet appel. Résultat : aucun débit silencieux depuis un serveur n'est possible.

Prix affiché inviolable

useGrinGhostAction appelle www.gringhost.com/api/public/session-token-info directement depuis le navigateur. Le prix affiché vient de GrinGhost, pas du serveur du développeur. Un dev malveillant ne peut pas montrer "1 crédit" pour faire débiter "10 crédits".

Anti-double-débit

  • Session token usage unique — GrinGhost refuse un token déjà utilisé
  • TTL 30s — token expiré = erreur côté serveur (côté client : timeout à 28s)
  • idempotency_key UNIQUE — retry réseau avec la même clé → GrinGhost retourne success: true sans débiter à nouveau
  • 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. Les augmentations sont bloquées 7 jours après création ou modification. Les baisses sont immédiates.


Documentation complète

gringhost.com/docs/dev