@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/reactCe 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 SupabaseGrinGhostButton— bouton autonome (login / menu / confirmation) — pas de propsuseGrinGhost— hook pour lireuser,credits,loadCreditsuseGrinGhostAction— 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/debitPré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/dev → Mes Sites → Nouveau Site
Récupère :
sandbox_api_key/api_key— clé API de ton siteclient_id/client_secret— credentials OAuthid— 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 ← prod3. 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éponseSandbox 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_keyUNIQUE — retry réseau avec la même clé → GrinGhost retournesuccess: truesans 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éditsLes 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.
