SKILL DETAIL
insforge-integrations
insforge/insforge-skills/insforge-integrations
This skill covers integrating third-party providers with InsForge. Currently two categories are supported: auth providers (RLS via JWT claims) and payment facilitators (x402 HTTP payment protocol). Each provider has its own guide under this directory. Auth providers include Clerk, Auth0, WorkOS, Kinde, Stytch, and Better Auth. Payment facilitators include OKX x402. Common patterns include: auth providers sign or issue a JWT containing the user's ID, pass it to InsForge via accessToken in createClient(), InsForge exposes claims through auth.jwt() in SQL, and RLS policies use a requesting_user_id() function to enforce row-level security. Payment facilitators involve the server returning 402 Payment Required, the client signing an EIP-3009 authorization, the server forwarding to the facilitator's /verify and /settle endpoints, and recording the settled payment in an InsForge table.
Installation
npx skills add https://github.com/insforge/insforge-skills --skill insforge-integrations
技能檔案
SKILL.md
最近同步 · 2026年8月29日
agents/openai.yaml›
interface:
display_name: "InsForge Integrations"
short_description: "Wire external auth or x402 payments into InsForge."
brand_color: "#17B26A"
default_prompt: "Use $insforge-integrations to wire Clerk auth into InsForge RLS."
references/auth0.md›
# InsForge + Auth0 Integration Guide
Auth0 signs an InsForge-compatible JWT inside a **Post Login Action**, embeds it as a custom claim on the ID token, and the Next.js app extracts it to pass to the InsForge client as `accessToken` (deprecated alias: `edgeFunctionToken`). InsForge validates the token and uses the `sub` claim for Row Level Security.
## Key packages
- `@auth0/nextjs-auth0` — Auth0 SDK for Next.js (use v4+)
- `@insforge/sdk` — InsForge client
## Recommended Workflow
```text
1. Create Auth0 application → Auth0 Dashboard (manual)
2. Create/link InsForge project → npx -y @insforge/cli create or link
3. Create Post Login Action → Auth0 Dashboard (manual, paste code below)
4. Install deps + configure env → npm install, .env.local
5. Set up Auth0 client → lib/auth0.ts with beforeSessionSaved
6. Set up middleware + layout → middleware.ts, app/layout.tsx
7. Create InsForge client utility → lib/insforge.ts
8. Set up InsForge database → requesting_user_id() + table + RLS
9. Build features → CRUD pages using InsForge client
```
## Dashboard setup (manual, cannot be automated)
### Auth0 Application
- Create a **Regular Web Application** in Auth0 Dashboard > Applications
- Set **Allowed Callback URLs** to `http://localhost:3000/auth/callback`
- Set **Allowed Logout URLs** to `http://localhost:3000`
- Note down **Domain**, **Client ID**, **Client Secret**
### Auth0 Post Login Action
- Create in Auth0 Dashboard > Actions > Library > Build Custom
- Name: `Generate InsForge Token`, trigger: **Post Login**
- Add `jsonwebtoken` as a dependency in the action editor
- Add `INSFORGE_JWT_SECRET` in the action's **Secrets** tab
- Deploy the action and drag it into the **post-login** trigger flow
### InsForge Project
- Create via `npx -y @insforge/cli create` or link via `npx -y @insforge/cli link --project-id <id>`
- Get the JWT secret via CLI: `npx -y @insforge/cli secrets get JWT_SECRET`
- Note down **URL** and **Anon Key** from InsForge, then store the CLI value in Auth0 as `INSFORGE_JWT_SECRET`
## Auth0 Post Login Action
This code runs in Auth0's environment (not your app). The action must sign a JWT with the InsForge secret and attach it as a namespaced custom claim on the ID token.
```javascript
const jwt = require('jsonwebtoken');
exports.onExecutePostLogin = async (event, api) => {
const insforgeToken = jwt.sign(
{
sub: event.user.user_id,
role: 'authenticated',
aud: 'insforge-api',
email: event.user.email,
},
event.secrets.INSFORGE_JWT_SECRET,
{ expiresIn: '1h' }
);
api.idToken.setCustomClaim('https://insforge.dev/insforge_token', insforgeToken);
};
```
## Auth0 v4 SDK — `beforeSessionSaved`
Auth0 v4 SDK **filters out custom claims** from the ID token by default. You **must** configure `beforeSessionSaved` on `Auth0Client` to extract the InsForge token into the session. Without this, `getSession().user` will not contain the token.
**The `idToken` parameter is a raw JWT string**, not a decoded object — you must split and base64url-decode it:
```typescript
// lib/auth0.ts
beforeSessionSaved: async (session, idToken) => {
if (idToken) {
const parts = idToken.split(".");
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
const insforgeToken = payload["https://insforge.dev/insforge_token"];
if (insforgeToken) {
(session.user ??= {})["https://insforge.dev/insforge_token"] = insforgeToken;
}
}
return session;
}
```
## Middleware
- Auth0 v4 uses `auth0.middleware()` exported directly from `middleware.ts` as `export const middleware = auth0.middleware()`
- No `app/api/auth/[auth0]/route.js` needed in v4
- Match paths: `/auth/:path*` and any protected routes
```typescript
// middleware.ts
import { auth0 } from "@/lib/auth0";
export const middleware = auth0.middleware();
export const config = {
matcher: ["/auth/:path*", "/protected/:path*"],
};
```
## Layout
- Wrap the app with `Auth0Provider` from `@auth0/nextjs-auth0/client` in `app/layout.tsx`
```typescript
import { Auth0Provider } from '@auth0/nextjs-auth0/client';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Auth0Provider>{children}</Auth0Provider>
</body>
</html>
);
}
```
## InsForge client
- Create a utility at `lib/insforge.ts` that calls `auth0.getSession()`, reads the token from `session.user["https://insforge.dev/insforge_token"]`, and passes it as `accessToken` to `createClient`
```typescript
// lib/insforge.ts
import { createClient } from '@insforge/sdk';
import { auth0 } from '@/lib/auth0';
export async function createInsForgeClient() {
const session = await auth0.getSession();
const insforgeToken = session?.user?.["https://insforge.dev/insforge_token"];
return createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL,
accessToken: insforgeToken,
});
}
```
## Database setup
- Auth0 user IDs are strings (e.g. `auth0|64a...`), not UUIDs — use `TEXT` columns for `user_id`
- Create a `requesting_user_id()` SQL function that extracts the `sub` claim from `auth.jwt()` as text
- Set `user_id` column default to `requesting_user_id()` so it auto-populates on insert
- Enable RLS and create policies that compare `user_id = requesting_user_id()`
```sql
create or replace function public.requesting_user_id()
returns text
language sql stable
as $$
select nullif(auth.jwt() ->> 'sub', '')::text
$$;
```
## Environment variables
| Variable | Source |
|----------|--------|
| `AUTH0_SECRET` | Generate with `openssl rand -hex 32` |
| `APP_BASE_URL` | `http://localhost:3000` |
| `AUTH0_DOMAIN` | Auth0 Dashboard |
| `AUTH0_CLIENT_ID` | Auth0 Dashboard |
| `AUTH0_CLIENT_SECRET` | Auth0 Dashboard |
| `NEXT_PUBLIC_INSFORGE_URL` | InsForge Dashboard |
| `NEXT_PUBLIC_INSFORGE_ANON_KEY` | InsForge Dashboard |
| `INSFORGE_JWT_SECRET` | InsForge CLI (`npx -y @insforge/cli secrets get JWT_SECRET`) |
## Common Mistakes
| Mistake | Solution |
|---------|----------|
| ❌ Forgetting `beforeSessionSaved` | ✅ Always configure it — without it the InsForge token is silently dropped |
| ❌ Treating `idToken` as a decoded object | ✅ It's a raw JWT string — split and base64url-decode the payload |
| ❌ Using `auth.uid()` for RLS policies | ✅ Use `requesting_user_id()` — Auth0 IDs are strings, not UUIDs |
| ❌ Creating `app/api/auth/[auth0]/route.js` | ✅ Not needed in v4 — `auth0.middleware()` handles it |
references/better-auth.md›
# InsForge + Better Auth Integration Guide
Better Auth is the only supported auth provider that **runs inside your own Postgres database** — there is no third-party SaaS in the loop. You point Better Auth at InsForge's Postgres via a connection string, it creates `user` / `session` / `account` / `verification` tables in a dedicated `better_auth` schema (hidden from PostgREST by construction — InsForge exposes only `public`), and a small bridge route on your app server signs an HS256 JWT for the InsForge HTTP API. Better Auth's `id` column is a string (not UUID), the same convention every other third-party integration here uses for `user_id`.
Three schemas, three owners — the integration boundary is structural, not procedural:
```
auth.* ← InsForge platform internals (project admins, OAuth) — untouched
better_auth.* ← Better Auth's tables (this integration)
public.* ← your app's data, with cross-schema FK to better_auth.user(id)
```
This guide covers two framework setups in detail:
- **Next.js (App Router)** — same-origin, fullstack; the easy path
- **Vite + React** (or any standalone React SPA) — needs a small Node server somewhere to host BA's routes; covered in [its own section](#vite--react-only-setups)
The auth/bridge primitives are framework-agnostic — `lib/auth.ts`, the schema setup, RLS policies, plugins, and the `useInsforgeClient` hook are identical across both. Only the route-handler shape and a few env-var prefixes differ.
## Recommended Workflow
For Next.js apps, the InsForge CLI scaffolds every file in this guide in one command:
```bash
npx -y @insforge/cli link --auth better-auth # or create --auth better-auth for a fresh dir
npm install
npm run setup # creates better_auth schema, runs BA migrate, sets up notes + RLS
npm run dev
```
The scaffold is overlay-safe — existing files are preserved, `package.json` is deep-merged, and env collisions are auto-resolved.
The rest of this guide is the **reference layer**: what each scaffolded file looks like, why it's shaped that way, how to extend it (plugins, custom claims, magic-link, two-factor), and how to run on non-Next stacks. Read the section that matches what you're customizing — you don't need to read top-to-bottom unless you're integrating manually.
> **Integrating manually** (no CLI, or non-Next stack)? Sequence: (1) `npx -y @insforge/cli create` or `link`, (2) `npx -y @insforge/cli secrets get JWT_SECRET`, (3) install deps and fill `.env.local`, (4) write `lib/auth.ts` with `search_path` set to `better_auth, public` (so BA's tables go in the dedicated schema), (5) `CREATE SCHEMA better_auth`, (6) `npx -y @better-auth/cli migrate`, (7) BA route handler, (8) bridge route, (9) `requesting_user_id()` + RLS + `notes` table FK'd to `better_auth.user(id)`, (10) `useInsforgeClient` (or server-side `createInsForgeClient`), (11) feature pages. Each numbered step has its own section below.
Starting point: `npx -y @insforge/cli link --auth better-auth` (or `create`) scaffolds a working Next 15 + BA project. The `--auth` flag is canonical; the rest of this guide explains the pieces it generates. For Vite/React or other non-Next stacks, see [Vite / React-only setups](#vite--react-only-setups) below — the proxy config and bridge route map directly.
## Key packages
- `better-auth` — Better Auth core
- `@better-auth/cli` — for `npx -y @better-auth/cli migrate`
- `pg` — Postgres driver (Better Auth wraps this)
- `jsonwebtoken` + `@types/jsonwebtoken` — server-side JWT signing for the bridge
- `@insforge/sdk` — InsForge client
## Dashboard setup (manual, cannot be automated)
### InsForge Project
- Create via `npx -y @insforge/cli create` or link via `npx -y @insforge/cli link --project-id <id>`
- Get the JWT secret: `npx -y @insforge/cli secrets get JWT_SECRET` — used to sign the bridge JWT
- Get the Postgres connection string for Better Auth's pool — for self-hosted InsForge, the docker-compose exposes `POSTGRES_PORT` (default `5432`) with the project's `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB`
- Note **Base URL** and **Anon Key** from the InsForge dashboard
### Better Auth
No SaaS dashboard. Better Auth runs entirely in your code + your Postgres.
## Better Auth configuration
```ts
// lib/auth.ts
import { betterAuth } from 'better-auth';
import { Pool } from 'pg';
// Fail at module-load if a required var is missing. Better than `!`
// because the error names the missing var instead of crashing on a
// downstream undefined. Used for server-side env vars throughout this
// guide. Client-side `NEXT_PUBLIC_*` reads keep the `!` syntax — those
// are inlined at build time, so a module-load check would just fire in
// the browser at request time anyway.
function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing required env var: ${name}`);
return v;
}
// BA's tables live in the dedicated `better_auth` schema. PostgREST exposes
// only `public` by default, so this isolation is what keeps user emails out
// of the data API — no REVOKE step needed. `pg.Pool` doesn't take a `schema`
// option, so we set search_path on every new pooled connection. BA's CLI
// (`better-auth migrate`) imports this file, so search_path applies during
// migrate too — its `CREATE TABLE`s land in `better_auth.*`, not `public`.
const pool = new Pool({ connectionString: requireEnv('DATABASE_URL') });
pool.on('connect', (client) => {
client.query('SET search_path TO better_auth, public').catch(() => { /* noop */ });
});
export const auth = betterAuth({
database: pool,
emailAndPassword: { enabled: true },
secret: requireEnv('BETTER_AUTH_SECRET'), // Better Auth's own session secret — different from InsForge's JWT_SECRET
baseURL: requireEnv('BETTER_AUTH_URL'), // e.g. http://localhost:3000
});
```
```ts
// lib/auth-client.ts
import { createAuthClient } from 'better-auth/react';
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL!,
});
```
### Schema setup + first migrate
Create the schema once, then run BA's migrate — it'll create its four tables in `better_auth.*` because of the `search_path` set in `lib/auth.ts`:
```sql
-- Run BEFORE auth:migrate so the schema exists when BA tries CREATE TABLE.
CREATE SCHEMA IF NOT EXISTS better_auth;
```
```bash
npx -y @better-auth/cli migrate --config ./lib/auth.ts -y
```
Creates `better_auth.user`, `better_auth.session`, `better_auth.account`, `better_auth.verification`. Idempotent — re-run any time you add `additionalFields`.
> **Why this is enough.** PostgREST is configured to expose only the `public` schema (`PGRST_DB_SCHEMAS=public`). Anything in `better_auth` is invisible to the data API, so anon and authenticated SDK calls return `404 relation "public.user" does not exist` instead of leaking emails. No REVOKE step. The InsForge dashboard reaches `better_auth.*` through its admin route (postgres superuser pool, role-independent), so Studio inspection works.
> **What about future plugins?** BA plugins that add tables (`organization`, `twoFactor`, `apiKey`, `passkey`, …) write to whatever schema BA's pool sees in `search_path` — i.e., `better_auth`. They inherit the same isolation automatically. No per-plugin REVOKE template needed.
## Better Auth route handlers (Next.js)
```ts
// app/api/auth/[...all]/route.ts
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
export const { POST, GET } = toNextJsHandler(auth);
```
For Vite/React or other non-Next setups (Hono, Express, Fastify, Bun), see [Vite / React-only setups](#vite--react-only-setups).
## The bridge route (Next.js)
This is where the integration lives. Better Auth's own `jwt()` plugin issues asymmetric tokens (EdDSA/ES256/RS256) which InsForge's PostgREST cannot verify — it expects HS256 signed with the InsForge JWT secret. So we re-sign:
```ts
// app/api/insforge-token/route.ts
import { auth } from '@/lib/auth';
import jwt from 'jsonwebtoken';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
export async function GET() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: 'not signed in' }, { status: 401 });
}
// Mint the smallest claim set InsForge needs. Don't add email or other PII —
// RLS reads sub via auth.jwt() ->> 'sub' and that's all that matters.
const token = jwt.sign(
{
sub: session.user.id,
role: 'authenticated',
aud: 'insforge-api',
},
requireEnv('INSFORGE_JWT_SECRET'),
{ algorithm: 'HS256', expiresIn: '1h' },
);
// no-store: bridge tokens are short-lived and per-session — never cache.
return NextResponse.json(
{ token },
{ headers: { 'Cache-Control': 'no-store' } },
);
}
```
Same shape and claims as the WorkOS / Auth0 / Kinde / Stytch guides — only difference is the session is read from Better Auth instead of a SaaS provider. The non-Next equivalent (Hono / Express) is in [Vite / React-only setups](#vite--react-only-setups).
## InsForge client
Two patterns, same as the existing five guides. **Pattern A** is the default; **Pattern B** is for SSR-heavy apps.
### Pattern A — long-lived client + imperative refresh (SPA / client components)
Same shape as the Clerk integration. Better Auth's `useSession()` provides reactive sign-in/sign-out state. **Framework-agnostic React** — works identically in Next.js client components and standalone Vite/CRA/etc apps; only the env-var accessor differs (`process.env.NEXT_PUBLIC_*` vs `import.meta.env.VITE_*`).
```tsx
// lib/insforge.ts
'use client'; // Next.js — drop this directive in Vite / non-Next setups
import { AuthChangeEvent, createClient, type InsForgeClient } from '@insforge/sdk';
import { authClient } from './auth-client';
import { useEffect, useMemo, useRef, useState } from 'react';
const REFRESH_INTERVAL_MS = 50 * 60 * 1000; // 50 min for a 1h bridge JWT
// Bridge JWT → HTTP auth and realtime handshake auth.
// SDK ≥ 1.4.4: default setAccessToken(token) means SIGNED_IN. For same-user
// bridge JWT rotation, pass AuthChangeEvent.TOKEN_REFRESHED so Realtime keeps
// the current socket and uses the fresh JWT on the next handshake.
// On SDK < 1.3.0 the public method doesn't exist — see the legacy fallback below.
export function useInsforgeClient(): { client: InsForgeClient; isReady: boolean } {
const session = authClient.useSession();
const [isReady, setIsReady] = useState(false);
const currentUserIdRef = useRef<string | null>(null);
const client = useMemo(
() =>
createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_BASE_URL!,
anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY!,
autoRefreshToken: false,
}),
[],
);
useEffect(() => {
if (!session.data?.user) {
client.setAccessToken(null);
currentUserIdRef.current = null;
setIsReady(false);
return;
}
const userId = session.data.user.id;
// User switched (A → B) without signing out first: drop the previous user's
// token and mark not-ready before the async fetch, so no request goes out
// authenticated as the old user while the new token is in flight.
if (currentUserIdRef.current !== null && currentUserIdRef.current !== userId) {
client.setAccessToken(null);
setIsReady(false);
}
let cancelled = false;
const refresh = async () => {
try {
const res = await fetch('/api/insforge-token', { credentials: 'same-origin' });
if (!res.ok) throw new Error(`bridge ${res.status}`);
const { token } = await res.json();
if (cancelled) return;
if (typeof token !== 'string' || !token) throw new Error('bridge: no token in response');
const event =
currentUserIdRef.current === userId
? AuthChangeEvent.TOKEN_REFRESHED
: AuthChangeEvent.SIGNED_IN;
client.setAccessToken(token, event);
currentUserIdRef.current = userId;
setIsReady(true);
} catch {
if (cancelled) return;
client.setAccessToken(null);
currentUserIdRef.current = null;
setIsReady(false);
}
};
void refresh();
const id = setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, [client, session.data?.user?.id]);
return { client, isReady };
}
```
> **SDK version note.** `client.setAccessToken(token, event)` supports explicit auth event semantics as of SDK **1.4.4**. Use `AuthChangeEvent.TOKEN_REFRESHED` only for same-user JWT rotation; use the default `SIGNED_IN` behavior for the first token or a changed user. `client.setAccessToken(token)` is public as of SDK **1.3.0**. On older SDKs (< 1.3.0) the public method doesn't exist, and you must update both the HTTP client and realtime manually with this fallback helper:
```ts
// Legacy fallback for SDK < 1.3.0 only — on 1.3.0+ call client.setAccessToken(token) directly.
function setBridgeToken(client: InsForgeClient, token: string | null) {
client.getHttpClient().setAuthToken(token);
(client.realtime as unknown as { tokenManager: { setAccessToken: (t: string | null) => void } })
.tokenManager.setAccessToken(token);
}
```
On SDK < 1.3.0, also replace each `client.setAccessToken(token)` / `client.setAccessToken(null)` call in the hook above with `setBridgeToken(client, token)` / `setBridgeToken(client, null)` — dropping in the helper alone isn't enough. Updating only the HTTP client leaves the realtime WebSocket on the anon key, so `senderId` shows the anon UUID instead of the user's Better Auth id.
### Pattern B — per-request client construction (server components, route handlers)
Same shape as the WorkOS / Auth0 / Kinde / Stytch guides. Use this in RSC or server actions.
```ts
// lib/insforge.server.ts
import { createClient } from '@insforge/sdk';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import jwt from 'jsonwebtoken';
export async function createInsForgeClient() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) return null;
const insforgeToken = jwt.sign(
{
sub: session.user.id,
role: 'authenticated',
aud: 'insforge-api',
},
requireEnv('INSFORGE_JWT_SECRET'),
{ algorithm: 'HS256', expiresIn: '1h' },
);
return createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_BASE_URL!,
accessToken: insforgeToken,
});
}
```
### Sign-out
Better Auth sign-out doesn't clear the InsForge SDK's in-memory token. Pattern A handles this automatically via the `useEffect` cleanup; if you sign out outside of React, do it explicitly with `client.setAccessToken(null)` (clears both HTTP and realtime in one call):
```ts
await authClient.signOut();
client.setAccessToken(null); // SDK ≥ 1.3.0 — clears HTTP + realtime
```
On SDK < 1.3.0 the public method doesn't exist; clear both manually instead:
```ts
await authClient.signOut();
client.getHttpClient().setAuthToken(null);
// tokenManager is private at compile-time, accessible at runtime — cast to reach it.
(client.realtime as unknown as { tokenManager: { setAccessToken: (t: string | null) => void } })
.tokenManager.setAccessToken(null);
```
## Database setup
Better Auth user IDs are **strings** (e.g. `f5kGYiUXDPEJqRDQ4jgtNTopIzpj5MgK`), not UUIDs. Use `TEXT` for any FK referencing them, and FK to `better_auth.user(id)` — never to `auth.users(id)` (InsForge's separate native auth table, UUID id, irrelevant to BA).
```sql
-- 0. ensure gen_random_uuid() is available (idempotent)
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- 1. helper that extracts sub claim from auth.jwt()
CREATE OR REPLACE FUNCTION public.requesting_user_id()
RETURNS text
LANGUAGE sql STABLE
AS $$
SELECT NULLIF(auth.jwt() ->> 'sub', '')::text
$$;
-- 2. example: a notes table owned by Better Auth users
-- All statements below are rerun-safe so this script can be applied repeatedly
-- (Postgres has no CREATE POLICY IF NOT EXISTS through PG17, so DROP IF EXISTS first).
CREATE TABLE IF NOT EXISTS public.notes (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id text NOT NULL DEFAULT public.requesting_user_id()
REFERENCES better_auth."user"(id) ON DELETE CASCADE,
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS notes_owner_select ON public.notes;
CREATE POLICY notes_owner_select ON public.notes
FOR SELECT TO authenticated
USING (user_id = public.requesting_user_id());
DROP POLICY IF EXISTS notes_owner_insert ON public.notes;
CREATE POLICY notes_owner_insert ON public.notes
FOR INSERT TO authenticated
WITH CHECK (user_id = public.requesting_user_id());
DROP POLICY IF EXISTS notes_owner_update ON public.notes;
CREATE POLICY notes_owner_update ON public.notes
FOR UPDATE TO authenticated
USING (user_id = public.requesting_user_id())
WITH CHECK (user_id = public.requesting_user_id());
DROP POLICY IF EXISTS notes_owner_delete ON public.notes;
CREATE POLICY notes_owner_delete ON public.notes
FOR DELETE TO authenticated
USING (user_id = public.requesting_user_id());
GRANT USAGE ON SCHEMA public TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.notes TO authenticated;
NOTIFY pgrst, 'reload schema';
```
Prefer running this through the InsForge CLI (`npx -y @insforge/cli db migrations new ... && npx -y @insforge/cli db migrations up`) — the CLI emits the `NOTIFY` automatically. Raw `psql` works but you must send the notify yourself or PostgREST returns `404 {}` until next reload.
## Realtime (optional)
If you use `client.realtime`, two extra one-time setup steps are needed because Better Auth IDs are strings (not UUIDs) and InsForge realtime currently requires both manual channel registration and a column-type fix.
```sql
-- 1. Allow string sender_ids (matches the rest of the third-party convention)
ALTER TABLE realtime.messages ALTER COLUMN sender_id TYPE text;
-- 2. Register a channel pattern (admin-only operation; do this once)
INSERT INTO realtime.channels (pattern, description, enabled)
VALUES ('chat:%', 'app chat channels', TRUE)
ON CONFLICT (pattern) DO NOTHING;
```
The channel pattern uses SQL `LIKE` syntax — `chat:%` matches `chat:lobby`, `chat:dm:user_xyz`, etc.
`client.setAccessToken(token, event)` (Pattern A) updates the HTTP token and the token used for future realtime handshakes. Use the default event for the first token or a changed Better Auth user, and `AuthChangeEvent.TOKEN_REFRESHED` for periodic same-user bridge JWT rotation so an active Realtime socket is not needlessly reconnected. Pattern B (`createClient({ accessToken: ... })`) handles construction-time auth automatically (`edgeFunctionToken` is the deprecated alias).
After the SQL fixes above: a two-user realtime broadcast verifies end-to-end — `senderId` on the received message equals the publisher's Better Auth `id`.
## Email transport (verification + password reset)
Better Auth invokes `sendVerificationEmail` and `sendResetPassword` callbacks on signup and reset flows. Wire those callbacks to InsForge's `client.emails.send()` so all transactional mail goes through one provider.
```ts
// lib/auth.ts
import { betterAuth } from 'better-auth';
import { createClient } from '@insforge/sdk';
import jwt from 'jsonwebtoken';
import { Pool } from 'pg';
// Per-call helper — BA callbacks fire server-side without an end-user JWT,
// so mint a short-lived service-style HS256 token signed with the SAME
// secret that the bridge route uses. Reusing INSFORGE_JWT_SECRET keeps the
// trust boundary minimal.
function insforgeServerClient() {
const token = jwt.sign(
{ sub: 'better-auth-service', role: 'authenticated', aud: 'insforge-api' },
requireEnv('INSFORGE_JWT_SECRET'),
{ algorithm: 'HS256', expiresIn: '5m' },
);
const c = createClient({ baseUrl: process.env.NEXT_PUBLIC_INSFORGE_BASE_URL! });
c.getHttpClient().setAuthToken(token);
return c;
}
export const auth = betterAuth({
database: new Pool({ connectionString: requireEnv('DATABASE_URL') }),
secret: requireEnv('BETTER_AUTH_SECRET'),
baseURL: requireEnv('BETTER_AUTH_URL'),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
const insforge = insforgeServerClient();
const { error } = await insforge.emails.send({
to: user.email,
subject: 'Reset your password',
html: `<p>Click <a href="${url}">here</a> to reset.</p>`,
});
if (error) throw new Error(error.message);
},
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => {
const insforge = insforgeServerClient();
const { error } = await insforge.emails.send({
to: user.email,
subject: 'Verify your email',
html: `<p>Hi ${user.name ?? ''}, click <a href="${url}">here</a> to verify.</p>`,
});
if (error) throw new Error(error.message);
},
},
});
```
### Where InsForge actually sends from
`client.emails.send` calls `POST /api/email/send-raw`. InsForge resolves the provider per-call:
1. **SMTP** — if you set SMTP credentials via `PUT /api/auth/smtp-config` (admin token), every send goes through your SMTP server.
2. **Cloud fallback** — if no SMTP is configured, InsForge tries its managed cloud relay. Requires `PROJECT_ID` (set automatically on cloud-hosted projects; missing on self-hosted).
For self-hosted dev, configure SMTP first or you'll get `INTERNAL_ERROR: PROJECT_ID is not configured`. The `/api/auth/smtp-config` PUT validates and **rejects loopback / private addresses** as an SSRF guard, so for a local maildev/mailpit you need a non-loopback hostname (e.g. a `.local` record on your LAN, or expose maildev publicly via ngrok).
### Why a service token, not the bridge route
The bridge route (`/api/insforge-token`) is for end-user requests — it reads BA's session cookie and signs a JWT with `sub = user.id`. But `sendVerificationEmail` runs **before** the user has a session (during signup). A 5-minute service-token JWT signed with `INSFORGE_JWT_SECRET` clears the auth check at `/api/email/send-raw` and is the equivalent of a "service role" call.
## Better Auth plugins (optional)
Better Auth ships ~37 plugins. Most are drop-in (`twoFactor`, `magicLink`, `username`) and require no InsForge-side changes. Plugins that **add tables** create them in `better_auth` automatically — the pool's `search_path` is already scoped to that schema, so new tables inherit the same isolation as the core four. No per-plugin REVOKE template, no per-plugin lockdown step.
### Organization plugin
Adds five tables (`organization`, `team`, `member`, `teamMember`, `invitation`) and two columns on `session` (`activeOrganizationId`, `activeTeamId`):
```ts
// lib/auth.ts
import { organization } from 'better-auth/plugins';
export const auth = betterAuth({
// ...
plugins: [
organization({ teams: { enabled: true } }),
],
});
```
Re-run `npx -y @better-auth/cli migrate -y` — that's it. The new tables land in `better_auth.organization`, `better_auth.team`, `better_auth.member`, `better_auth.teamMember`, `better_auth.invitation`. Verify with `curl http://<insforge>/organization?select=id` (anon) — should return `404 relation "public.organization" does not exist` because PostgREST never sees the schema.
For multi-tenant RLS on app tables, add `org_id` as a custom JWT claim by reading `session.activeOrganizationId` in the bridge route:
```ts
// app/api/insforge-token/route.ts (delta)
const token = jwt.sign(
{
sub: session.user.id,
role: 'authenticated',
aud: 'insforge-api',
org_id: session.session.activeOrganizationId ?? null, // ← add
},
requireEnv('INSFORGE_JWT_SECRET'),
{ algorithm: 'HS256', expiresIn: '1h' },
);
```
Then in policies use `auth.jwt() ->> 'org_id'` alongside `requesting_user_id()`.
### Other table-adding plugins
| Plugin | Tables added (all in `better_auth` schema) |
|--------|--------------------------------------------|
| `twoFactor` | `twoFactor` |
| `apiKey` | `apikey` |
| `passkey` | `passkey` |
| `oidcProvider` | `oauthApplication`, `oauthAccessToken`, `oauthConsent` |
Rule of thumb: after every `auth migrate`, `\dt better_auth.*` to see what BA created. No follow-up REVOKE step — `search_path` did the isolation for you.
## Vite / React-only setups
A pure React SPA (Vite, CRA, RSPack, …) has no built-in server, so you need a small Node/Bun process to host BA's `/api/auth/*` routes plus the bridge route. Two well-trodden patterns:
| Pattern | When to use | Origin model |
|---|---|---|
| **A. Vite proxy → Next.js / Hono / Express** | Already have (or want) a backend; cleanest for local dev | Same-origin to the browser (proxy hides the server) |
| **B. Cross-origin React + standalone BA server** | True microservice split; multi-domain prod | Different origins; needs explicit CORS + cookie config |
### Pattern A — Vite proxy
`vite.config.ts` proxies `/api` to the BA server. To the browser everything is on `:5173`, so the BA cookie is auto-attached to `/api/insforge-token` with no CORS dance.
```ts
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3030', // your BA / bridge server
changeOrigin: true,
configure: (proxy) => {
// Rewrite Origin so BA's CSRF check (which compares the request's
// Origin against its `baseURL`) sees its own URL. Without this,
// sign-out (and any other state-changing endpoint that enforces
// Origin) returns 403 because the browser's Origin is :5173, not
// :3030. Sign-up has looser Origin handling, which is why this
// bug only shows up later in the flow and is easy to miss.
proxy.on('proxyReq', (proxyReq) => {
proxyReq.setHeader('origin', 'http://localhost:3030');
});
},
},
},
},
});
```
Equivalent without the rewrite: add `trustedOrigins: ['http://localhost:5173']` to `betterAuth({...})`. Either approach works; the proxy rewrite keeps `lib/auth.ts` unchanged.
### The bridge route in non-Next servers
The Next.js route handler from earlier maps directly. Hono on Bun:
```ts
// server.ts (Hono — also works on Node via @hono/node-server, or Bun.serve)
import { Hono } from 'hono';
import { auth } from './lib/auth';
import jwt from 'jsonwebtoken';
const app = new Hono();
// 1. Better Auth catch-all — equivalent of toNextJsHandler
app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw));
// 2. Bridge route — exact same JWT shape as the Next version (sub/role/aud only,
// no PII; see lines 138–148 above for why).
app.get('/api/insforge-token', async (c) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session?.user) return c.json({ error: 'not signed in' }, 401);
const token = jwt.sign(
{
sub: session.user.id,
role: 'authenticated',
aud: 'insforge-api',
},
requireEnv('INSFORGE_JWT_SECRET'),
{ algorithm: 'HS256', expiresIn: '1h' },
);
return c.json({ token }, 200, { 'Cache-Control': 'no-store' });
});
export default { port: 3030, fetch: app.fetch };
```
Express is the same shape — `app.all('/api/auth/*', toNodeHandler(auth))` (use `better-auth/node` instead of `better-auth/next-js`), then a regular `app.get('/api/insforge-token', ...)` handler. The JWT signing block is identical.
### Env vars in Vite
Vite exposes `import.meta.env.VITE_*` instead of `process.env.NEXT_PUBLIC_*`. Anything server-only (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `INSFORGE_JWT_SECRET`) reads from `process.env` in the BA server process — same as Next.
```bash
# .env.local (Vite project)
VITE_BETTER_AUTH_URL=http://localhost:5173 # the SPA origin (or BA origin if cross-origin)
VITE_INSFORGE_BASE_URL=http://localhost:7130
VITE_INSFORGE_ANON_KEY=<from InsForge dashboard>
```
`lib/auth-client.ts` and `lib/insforge.ts` then read `import.meta.env.VITE_BETTER_AUTH_URL` etc. The Pattern A hook code shown earlier is otherwise identical.
### Pattern B — true cross-origin (no proxy)
If you really want the React app and the BA server on different origins (e.g., `app.example.com` and `auth.example.com`), four changes vs the same-origin path:
1. **BA cookie config**: `advanced: { defaultCookieAttributes: { sameSite: 'none', secure: true } }` so the cookie crosses origins. `secure: true` requires HTTPS — `localhost` counts as a secure context in Chrome/Firefox/Safari, so this works in dev too.
2. **`trustedOrigins`** on the BA config: `trustedOrigins: ['https://app.example.com']`. Without this, BA's CSRF check rejects POSTs from the SPA with 403 (sign-out, etc. — sign-up is more lenient and won't fail, masking the issue).
3. **Bridge route CORS**: `Access-Control-Allow-Credentials: true` and an explicit `Access-Control-Allow-Origin: <app origin>` (not `*`). Plus a preflight `OPTIONS` handler.
4. **Client fetch**: `fetch('/api/insforge-token', { credentials: 'include' })` — without it the BA cookie isn't sent.
Forget any of the four and the bridge silently sees no session.
## Environment variables
Server-only vars (read via `process.env` in the BA process) are the same across both setups. Browser-exposed vars use `NEXT_PUBLIC_*` in Next.js and `VITE_*` in Vite.
| Variable | Source | Where read |
|----------|--------|-----------|
| `DATABASE_URL` | InsForge Postgres connection string | Server (BA's `Pool`) |
| `BETTER_AUTH_SECRET` | random — `openssl rand -base64 32` | Server (BA session signing) |
| `BETTER_AUTH_URL` | your BA server URL | Server (`baseURL` in `betterAuth()`) |
| `INSFORGE_JWT_SECRET` | `npx -y @insforge/cli secrets get JWT_SECRET` | Server (bridge route HS256 signing) |
| `NEXT_PUBLIC_BETTER_AUTH_URL` *(Next.js)* / `VITE_BETTER_AUTH_URL` *(Vite)* | same as `BETTER_AUTH_URL` (or the SPA origin if proxying) | Browser (`authClient` baseURL) |
| `NEXT_PUBLIC_INSFORGE_BASE_URL` / `VITE_INSFORGE_BASE_URL` | InsForge dashboard | Browser (SDK baseUrl) |
| `NEXT_PUBLIC_INSFORGE_ANON_KEY` / `VITE_INSFORGE_ANON_KEY` | InsForge dashboard | Browser (SDK anonKey) |
## Common Mistakes
| Mistake | Solution |
|---------|----------|
| ❌ Forgetting `NOTIFY pgrst, 'reload schema'` after raw psql DDL | ✅ PostgREST returns `404 {}` until reloaded. Use the InsForge CLI for migrations and the notify happens automatically. |
| ❌ Using Better Auth's `jwt()` plugin directly with InsForge | ✅ It issues asymmetric (EdDSA/ES256/RS256) tokens; InsForge's PostgREST verifies HS256. Use the bridge route instead. |
| ❌ Using `auth.uid()` for RLS policies | ✅ Use `requesting_user_id()` — Better Auth IDs are strings, not UUIDs. |
| ❌ FK'ing to `auth.users(id)` (or `public.user(id)`) | ✅ FK to `better_auth.user(id)` — that's where Better Auth puts its tables. `auth.users` is InsForge's native auth (UUID id, irrelevant here); `public.user` no longer exists post-migrate. |
| ❌ Forgetting `CREATE SCHEMA better_auth` before the first `auth:migrate` | ✅ The migrate fails with "schema better_auth does not exist". The CLI scaffold's `npm run setup` runs schema → migrate → app SQL in order; if you migrate manually, create the schema first. |
| ❌ Setting `search_path` only inside the BA pool but FK'ing app tables to a schema-unqualified `"user"` | ✅ Outside BA's pool, the Postgres default search_path doesn't include `better_auth`. Always qualify FKs explicitly: `REFERENCES better_auth."user"(id)`. |
| ❌ Re-using `BETTER_AUTH_SECRET` as the InsForge JWT secret | ✅ They are independent. `BETTER_AUTH_SECRET` is for Better Auth's session cookies; `INSFORGE_JWT_SECRET` is the HS256 key for the bridge JWT. |
| ❌ Setting the token only once on mount (Pattern A) | ✅ Refresh on a ~50min interval for a 1h JWT, keyed on Better Auth's `useSession()`. |
| ❌ Forgetting `credentials: 'same-origin'` (or `'include'` cross-origin) on the bridge fetch | ✅ Without credentials, the Better Auth cookie isn't sent and the bridge always returns 401. |
| ❌ Cross-origin without `sameSite: 'none'; secure` on the BA cookie | ✅ The browser drops the cookie on cross-origin requests by default. Configure Better Auth's cookies for cross-origin explicitly. |
| ❌ Missing `Origin` header on direct `fetch`/`curl` to Better Auth POSTs | ✅ Better Auth requires `Origin` for CSRF. Browsers send it automatically; server-side clients must add `'Origin: <baseURL>'`. |
| ❌ Realtime client shows `senderId` as the anon UUID instead of the user's BA id (Pattern A only) | ✅ On SDK ≥ 1.4.4 call `client.setAccessToken(token)` for initial/changed-user auth and `client.setAccessToken(token, AuthChangeEvent.TOKEN_REFRESHED)` for same-user rotation. On SDK 1.3.x, `client.setAccessToken(token)` updates HTTP and realtime auth but refreshes may reconnect realtime. On SDK < 1.3.0 use the `setBridgeToken` legacy fallback from Pattern A. Pattern B's `accessToken` already pipes into both. |
| ❌ Realtime publish silently fails for authenticated users (`UNAUTHORIZED`) | ✅ `realtime.messages.sender_id` is `uuid` in core InsForge; Better Auth IDs are strings. One-time fix: `ALTER TABLE realtime.messages ALTER COLUMN sender_id TYPE text;` |
| ❌ Vite SPA proxying to a separate BA server, sign-out (or any state-changing endpoint) returns 403 | ✅ BA's CSRF check compares the request's `Origin` against its `baseURL`. Either rewrite the proxy's `Origin` header to BA's URL (Vite `proxy.configure`) or add the SPA origin to BA's `trustedOrigins`. Sign-up has looser handling and won't trip this — the bug shows up later. |
| ❌ Cross-origin missing `trustedOrigins` even with `sameSite: 'none'; secure: true` | ✅ Cookie config alone isn't enough — BA's CSRF gate also reads `trustedOrigins`. Add the SPA's full origin (no trailing slash) to the array. |
references/clerk.md›
# InsForge + Clerk Integration Guide (Next.js)
Clerk signs tokens with InsForge's JWT secret directly via a **JWT Template** — no server-side signing needed. The app calls `getToken({ template: 'insforge' })` and forwards the token to the InsForge client via `client.setAccessToken()`.
This guide targets **Next.js (App Router)**. The same pattern works in other React setups, but all examples and env vars assume Next.js.
## Key packages
- `@clerk/nextjs` — Clerk SDK for Next.js (includes `clerkMiddleware`, `ClerkProvider`, hooks, and prebuilt `<SignIn>` / `<SignUp>` components)
- `@insforge/sdk` — InsForge client
## Recommended Workflow
```text
1. Create Clerk application → Clerk Dashboard (manual)
2. Create/link InsForge project → npx -y @insforge/cli create or link
3. Create JWT template in Clerk → Clerk Dashboard (manual)
4. Install deps + configure env → npm install, .env.local
5. Wire ClerkProvider + middleware → app/layout.tsx + middleware.ts
6. Initialize InsForge client → createClient + setAccessToken with Clerk token (refresh on interval)
7. Set up InsForge database → requesting_user_id() + table + RLS
8. Build features → CRUD pages using InsForge client
```
## Dashboard setup (manual, cannot be automated)
### Clerk Application
- Create an application in Clerk Dashboard
- Note down **Publishable Key** and **Secret Key**
### Clerk JWT Template
- Create in Clerk Dashboard > Configure > JWT Templates > New template > Blank
- Name: `insforge`
- Signing algorithm: `HS256`
- Signing key: the InsForge JWT Secret
- Claims: `{ "role": "authenticated", "aud": "insforge-api" }`
- Do NOT add `sub` or `iss` — they are reserved and auto-included
### InsForge Project
- Create via `npx -y @insforge/cli create` or link via `npx -y @insforge/cli link --project-id <id>`
- Get the JWT secret via CLI: `npx -y @insforge/cli secrets get JWT_SECRET`
- Note down **URL** and **Anon Key** from InsForge, then use the CLI output as the signing key in Clerk
## Next.js wiring
### `middleware.ts` (project root)
```ts
import { clerkMiddleware } from '@clerk/nextjs/server';
export default clerkMiddleware();
export const config = {
matcher: [
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
'/(api|trpc)(.*)',
],
};
```
### `app/layout.tsx`
Wrap the app in `<ClerkProvider>` (it's a server component — no `'use client'` needed).
```tsx
import { ClerkProvider } from '@clerk/nextjs';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
);
}
```
### Sign-in / sign-up routes
Use Clerk's optional catch-all routes so Clerk's internal redirects work:
```text
app/sign-in/[[...sign-in]]/page.tsx
app/sign-up/[[...sign-up]]/page.tsx
```
```tsx
// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from '@clerk/nextjs';
export default function Page() {
return <SignIn path="/sign-in" signUpUrl="/sign-up" forceRedirectUrl="/" />;
}
```
## InsForge client
- Create the client once with `createClient({ baseUrl, anonKey })`
- Use Clerk's `useAuth()` to get `getToken`
- In a `useEffect` keyed on `isSignedIn` and Clerk `userId`, call `getToken({ template: 'insforge' })` and pipe the result into `client.setAccessToken(token, event)` (see the next bullet for choosing `event`)
- Clerk JWT templates default to **60-second expiry** — refresh the token on a ~50-second interval while the user is signed in; clear the token on sign-out
- For the initial token or a changed Clerk user, use the default `SIGNED_IN` behavior; for periodic same-user token rotation, pass `AuthChangeEvent.TOKEN_REFRESHED` so Realtime keeps the existing socket and uses the fresh JWT on the next handshake
- The template name `'insforge'` must match the Clerk dashboard exactly
- `@insforge/sdk`'s `accessToken` config field (deprecated alias: `edgeFunctionToken`) is a **static string**, not a function — it cannot auto-refresh on its own, which is why we use `client.setAccessToken()` imperatively
- This hook uses Clerk hooks, so the file must start with `'use client'`
```tsx
// lib/insforge.ts
'use client';
import { AuthChangeEvent, createClient, type InsForgeClient } from '@insforge/sdk';
import { useAuth } from '@clerk/nextjs';
import { useEffect, useMemo, useRef, useState } from 'react';
const TOKEN_REFRESH_MS = 50_000; // Clerk template tokens expire in 60s by default
export function useInsforgeClient(): { client: InsForgeClient; isReady: boolean } {
const { getToken, isSignedIn, userId } = useAuth();
const [isReady, setIsReady] = useState(false);
const currentUserIdRef = useRef<string | null>(null);
const client = useMemo(
() =>
createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_BASE_URL!,
anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY!,
}),
[],
);
useEffect(() => {
if (!isSignedIn || !userId) {
client.setAccessToken(null);
currentUserIdRef.current = null;
setIsReady(false);
return;
}
// User switched (A → B) without signing out first: drop the previous user's
// token and mark not-ready before the async fetch, so no request goes out
// authenticated as the old user while the new token is in flight.
if (currentUserIdRef.current !== null && currentUserIdRef.current !== userId) {
client.setAccessToken(null);
setIsReady(false);
}
let cancelled = false;
const refresh = async () => {
try {
const token = await getToken({ template: 'insforge' });
if (cancelled) return;
if (!token) {
client.setAccessToken(null);
currentUserIdRef.current = null;
setIsReady(false);
return;
}
const event =
currentUserIdRef.current === userId
? AuthChangeEvent.TOKEN_REFRESHED
: AuthChangeEvent.SIGNED_IN;
client.setAccessToken(token, event);
currentUserIdRef.current = userId;
setIsReady(true);
} catch (err) {
if (cancelled) return;
client.setAccessToken(null);
currentUserIdRef.current = null;
setIsReady(false);
console.error('Failed to refresh Clerk token for InsForge client', err);
}
};
void refresh();
const id = setInterval(() => void refresh(), TOKEN_REFRESH_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, [client, getToken, isSignedIn, userId]);
return { client, isReady };
}
```
> **SDK version note.** The two-arg `client.setAccessToken(token, event)` and the `AuthChangeEvent` import require SDK **≥ 1.4.4**. On SDK **1.3.x**, drop the second argument (and the `AuthChangeEvent` import) and call `client.setAccessToken(token)` — it still updates both HTTP and realtime auth, but a same-user refresh may reconnect the socket. On SDK **< 1.3.0** the public method doesn't exist; use the `setBridgeToken` legacy fallback shown in the Better Auth reference.
## Database setup
- Clerk user IDs are strings (e.g. `user_2xPnG8KxVQr`), not UUIDs — use `TEXT` columns for `user_id`
- Create a `requesting_user_id()` SQL function that extracts the `sub` claim from `auth.jwt()` as text
- Set `user_id` column default to `requesting_user_id()` so it auto-populates on insert
- Enable RLS and create policies that compare `user_id = requesting_user_id()`
```sql
create or replace function public.requesting_user_id()
returns text
language sql stable
as $$
select nullif(auth.jwt() ->> 'sub', '')::text
$$;
```
## Environment variables
| Variable | Source | Notes |
|----------|--------|-------|
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk Dashboard | Exposed to the browser |
| `CLERK_SECRET_KEY` | Clerk Dashboard | Server-only; required by `clerkMiddleware()` |
| `NEXT_PUBLIC_INSFORGE_BASE_URL` | InsForge Dashboard | Exposed to the browser |
| `NEXT_PUBLIC_INSFORGE_ANON_KEY` | InsForge Dashboard | Exposed to the browser |
## Common Mistakes
| Mistake | Solution |
|---------|----------|
| ❌ Passing an async function as `accessToken` | ✅ SDK accepts only a static string there — use `client.setAccessToken()` instead |
| ❌ Setting the token only once on mount | ✅ Refresh on a ~50s interval — Clerk JWT templates expire in 60s by default |
| ❌ Adding `sub` or `iss` to the JWT template | ✅ These are reserved claims, auto-included by Clerk |
| ❌ Using `auth.uid()` for RLS policies | ✅ Use `requesting_user_id()` — Clerk IDs are strings, not UUIDs |
| ❌ Omitting `CLERK_SECRET_KEY` in `.env.local` | ✅ `clerkMiddleware()` reads it at runtime — add it alongside the publishable key |
| ❌ Forgetting `'use client'` on `lib/insforge.ts` | ✅ The hook uses React + Clerk hooks; the file must be a client module |
references/kinde.md›
# InsForge + Kinde Integration Guide
Kinde **does not support custom JWT signing keys**, so you sign a separate JWT server-side using `jsonwebtoken`. The flow: get the Kinde user from the server session → sign a JWT with InsForge's secret → pass it to InsForge as `accessToken` (deprecated alias: `edgeFunctionToken`).
## Key packages
- `@kinde-oss/kinde-auth-nextjs` — Kinde SDK for Next.js
- `@insforge/sdk` — InsForge client
- `jsonwebtoken` + `@types/jsonwebtoken` — for server-side JWT signing
## Recommended Workflow
```text
1. Create Kinde application → Kinde Dashboard (manual)
2. Create/link InsForge project → npx -y @insforge/cli create or link
3. Install deps + configure env → npm install, .env.local
4. Create Kinde auth route → app/api/auth/[kindeAuth]/route.js
5. Create InsForge client utility → lib/insforge.ts (server-side JWT signing)
6. Set up InsForge database → requesting_user_id() + table + RLS
7. Build features → CRUD pages using InsForge client
```
## Dashboard setup (manual, cannot be automated)
### Kinde Application
- Create in Kinde Dashboard > Add application
- Type: **Back-end web**, SDK: **Next.js**
- Set **Allowed callback URL** to `http://localhost:3000/api/auth/kinde_callback`
- Set **Allowed logout redirect URL** to `http://localhost:3000`
- Enable desired auth methods (Email, Google, etc.) under Authentication
- Note down **Domain**, **Client ID**, **Client Secret** from App Keys
### InsForge Project
- Create via `npx -y @insforge/cli create` or link via `npx -y @insforge/cli link --project-id <id>`
- Get the JWT secret via CLI: `npx -y @insforge/cli secrets get JWT_SECRET`
- Note down **URL** and **Anon Key** from InsForge, then export the CLI value as `INSFORGE_JWT_SECRET`
## Kinde auth route
- Create `app/api/auth/[kindeAuth]/route.js` that exports `handleAuth()` from `@kinde-oss/kinde-auth-nextjs/server`
```javascript
// app/api/auth/[kindeAuth]/route.js
import { handleAuth } from "@kinde-oss/kinde-auth-nextjs/server";
export const GET = handleAuth();
```
## InsForge client
- Create a server-side utility at `lib/insforge.ts` — cannot be used in client components
- Use `getKindeServerSession()` to get `getUser`
- Sign a JWT with `jsonwebtoken` using `process.env.INSFORGE_JWT_SECRET`
- Required claims: `sub` (from `user.id`), `role: "authenticated"`, `aud: "insforge-api"`, `email`
- Set `expiresIn: '1h'`
- Pass the signed token as `accessToken` to `createClient`
```typescript
// lib/insforge.ts
import { createClient } from '@insforge/sdk';
import { getKindeServerSession } from '@kinde-oss/kinde-auth-nextjs/server';
import jwt from 'jsonwebtoken';
export async function createInsForgeClient() {
const { getUser } = getKindeServerSession();
const user = await getUser();
let accessToken: string | undefined;
if (user) {
accessToken = jwt.sign(
{
sub: user.id,
role: 'authenticated',
aud: 'insforge-api',
email: user.email,
},
process.env.INSFORGE_JWT_SECRET!,
{ expiresIn: '1h' }
);
}
return createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL!,
accessToken,
});
}
```
## Database setup
- Kinde user IDs are strings (e.g. `kp_1234abcd`), not UUIDs — use `TEXT` columns for `user_id`
- Create a `requesting_user_id()` SQL function that extracts the `sub` claim from `auth.jwt()` as text
- Set `user_id` column default to `requesting_user_id()` so it auto-populates on insert
- Enable RLS and create policies that compare `user_id = requesting_user_id()`
```sql
create or replace function public.requesting_user_id()
returns text
language sql stable
as $$
select nullif(auth.jwt() ->> 'sub', '')::text
$$;
```
## Environment variables
| Variable | Source |
|----------|--------|
| `KINDE_CLIENT_ID` | Kinde Dashboard > App Keys |
| `KINDE_CLIENT_SECRET` | Kinde Dashboard > App Keys |
| `KINDE_ISSUER_URL` | `https://YOUR_DOMAIN.kinde.com` |
| `KINDE_SITE_URL` | `http://localhost:3000` |
| `KINDE_POST_LOGOUT_REDIRECT_URL` | `http://localhost:3000` |
| `KINDE_POST_LOGIN_REDIRECT_URL` | `http://localhost:3000` |
| `NEXT_PUBLIC_INSFORGE_URL` | InsForge Dashboard |
| `NEXT_PUBLIC_INSFORGE_ANON_KEY` | InsForge Dashboard |
| `INSFORGE_JWT_SECRET` | InsForge CLI (`npx -y @insforge/cli secrets get JWT_SECRET`) |
## Common Mistakes
| Mistake | Solution |
|---------|----------|
| ❌ Using Kinde's JWT directly with InsForge | ✅ Kinde doesn't sign with your secret — sign a separate JWT server-side |
| ❌ Using InsForge client in a client component | ✅ `getKindeServerSession` is server-only — keep the utility server-side |
| ❌ Using `auth.uid()` for RLS policies | ✅ Use `requesting_user_id()` — Kinde IDs are strings, not UUIDs |
references/okx-x402.md›
# InsForge + OKX x402 Payments Integration Guide
OKX acts as the **x402 facilitator** for the x402 HTTP payment protocol. Your server returns `402 Payment Required` with a challenge; the client signs an EIP-3009 `TransferWithAuthorization`; the server forwards the signed payload to OKX's `/verify` and `/settle` endpoints. USDG on X Layer settles with zero gas (OKX pays the gas). Payment records and realtime dashboards live in InsForge.
## Key packages
- `@insforge/sdk` — InsForge client for DB writes, AI calls, and realtime subscription
- `viem` — EIP-712 typed data signing + chain switching on the client
- No x402 SDK is required; the facilitator is plain REST
## Recommended Workflow
Each step below maps 1:1 to an agent prompt. Run them in order — each step produces concrete files with a verification checkpoint before moving on.
```text
1. Schema & Realtime → migrations/db_init.sql + db import
2. Server Primitives & Env → lib/okx-facilitator.ts, lib/x402.ts, lib/insforge.ts, .env
3. Paid Endpoint & Aggregate → app/api/report + app/api/payments (AI content generator)
4. Client Libs & Consumer Flow → lib/x402-client.ts (incl. chain switch) + fetch→sign→retry state machine
5. Realtime Dashboard → SSR from /api/payments + subscribe x402_payments + try-it + report view
6. Diagnostics & Go-Live → scripts/check-domain.mjs, scripts/check-usdg.mjs, MOCK off, first real payment
```
## Prerequisites (manual, cannot be automated)
### OKX Web3 API credentials
- Go to [OKX Onchain OS Dev Portal](https://web3.okx.com/onchainos/dev-portal) and connect your wallet
- Create a project, then link email + phone (required to enable API key creation)
- Create API Key → save **API Key**, **Secret Key**, and the **passphrase** you set (secret shown only once)
- **Do NOT reuse an OKX exchange trading API key** — it returns `Invalid Authority` (code 50114). The Web3 API is a separate system at `web3.okx.com/onchainos/dev-portal`, not `okx.com/account/my-api`
### Payment recipient wallet
- Any EVM address on X Layer (chainId 196) works as the payee
- If using OKX Wallet, copy the **0x-prefixed** address (not the `XKO...` native format)
- Fund the **paying** wallet (not the recipient) with USDG on X Layer for real settlements — OKX facilitator covers gas
### InsForge project
- Create via `npx -y @insforge/cli create` or link via `npx -y @insforge/cli link --project-id <id>`
- Get **URL**, **Anon Key**, and **Service Role Key** from dashboard → Project Settings → API Keys
## Chain + Asset constants (X Layer)
| Constant | Value |
|----------|-------|
| Chain ID | `196` (hex `0xc4`) |
| CAIP-2 network | `eip155:196` |
| USDG contract | `0x4ae46a509f6b1d9056937ba4500cb143933d2dc8` |
| EIP-712 domain name | `Global Dollar` (NOT `"USDG"`) |
| EIP-712 domain version | `1` (NOT `"2"`) |
| Decimals | 6 |
| RPC | `https://rpc.xlayer.tech` |
**Domain name/version are the most common source of `Invalid Authority` errors.** Always run `scripts/check-domain.mjs` (Step 6) before the first real payment to confirm against the on-chain `DOMAIN_SEPARATOR`.
## Explorer URLs by chain
Render the tx hash in the dashboard as a link so users can verify on-chain:
| Chain (stored in `payments.chain`) | Explorer tx URL pattern |
|---|---|
| `xlayer` | `https://www.okx.com/web3/explorer/xlayer/tx/{hash}` |
| `base` | `https://basescan.org/tx/{hash}` |
| `optimism` | `https://optimistic.etherscan.io/tx/{hash}` |
| `arbitrum` | `https://arbiscan.io/tx/{hash}` |
```typescript
// src/lib/explorer.ts
const EXPLORERS: Record<string, string> = {
xlayer: "https://www.okx.com/web3/explorer/xlayer/tx/",
base: "https://basescan.org/tx/",
optimism: "https://optimistic.etherscan.io/tx/",
arbitrum: "https://arbiscan.io/tx/",
};
export function txUrl(chain: string | null, hash: string) {
return (EXPLORERS[chain ?? "xlayer"] ?? EXPLORERS.xlayer) + hash;
}
```
## Environment variables
| Variable | Source | Used by |
|----------|--------|---------|
| `OKX_API_KEY` | OKX Onchain OS Dev Portal (Web3 API, NOT exchange API) | server |
| `OKX_SECRET_KEY` | OKX Onchain OS Dev Portal | server |
| `OKX_PASSPHRASE` | Chosen by you when creating the API key | server |
| `PAYMENT_RECIPIENT` | Your EVM wallet address on X Layer (`0x...`) | server |
| `NEXT_PUBLIC_INSFORGE_URL` | InsForge Dashboard → Project Settings | client + server |
| `NEXT_PUBLIC_INSFORGE_ANON_KEY` | InsForge Dashboard → Project Settings | client |
| `INSFORGE_SERVICE_KEY` | InsForge Dashboard → Project Settings (server-only) | server |
| `OPENROUTER_API_KEY` | InsForge Dashboard → Model Gateway → Overview | server |
| `MOCK_OKX_FACILITATOR` | `true` for local/demo; unset or `false` for production | server |
| `NEXT_PUBLIC_MOCK_OKX_FACILITATOR` | Mirror of `MOCK_OKX_FACILITATOR` if you want the UI to show a "mock mode" badge | client |
**Mock mode contract:** `MOCK_OKX_FACILITATOR=true` skips real on-chain verify/settle on the server and returns a random tx hash. The client-side signing flow **does not change** — the browser still prompts the wallet for a real signature. This keeps the UX identical to production; only the server-side on-chain calls are mocked. Never set `MOCK_OKX_FACILITATOR=true` in production.
---
# Step 1 — Schema & Realtime
Create a single-file migration and apply it with `db import`. This is idempotent and works for both first-time setup and re-runs in fresh environments.
```sql
-- migrations/db_init.sql
-- 1. Payment ledger
create table if not exists x402_payments (
id uuid default gen_random_uuid() primary key,
payer_address text not null,
endpoint text not null,
amount text not null, -- smallest unit (6-decimal)
tx_hash text not null unique, -- UNIQUE prevents duplicate settlement records from retries
chain text default 'xlayer',
status text default 'settled',
response_summary text,
created_at timestamptz default now()
);
create index if not exists idx_x402_payments_payer on x402_payments (payer_address);
create index if not exists idx_x402_payments_created on x402_payments (created_at desc);
-- 2. RLS: public can read the ledger; writes come from the service key only
alter table x402_payments enable row level security;
drop policy if exists public_read on x402_payments;
create policy public_read on x402_payments for select using (true);
-- 3. Realtime channel
insert into realtime.channels (pattern, description, enabled)
values ('x402_payments', 'Payment events for dashboard', true)
on conflict do nothing;
-- 4. Trigger: publish every INSERT to the realtime channel
create or replace function notify_x402_payment()
returns trigger as $$
begin
perform realtime.publish(
'x402_payments',
'INSERT_x402_payments',
jsonb_build_object('new', row_to_json(new))
);
return new;
end;
$$ language plpgsql security definer
set search_path = pg_catalog, public, realtime;
drop trigger if exists x402_payment_realtime on x402_payments;
create trigger x402_payment_realtime
after insert on x402_payments
for each row
execute function notify_x402_payment();
```
Apply:
```bash
npx -y @insforge/cli db import migrations/db_init.sql
```
**✓ Verify**
```bash
npx -y @insforge/cli db query "select count(*) from x402_payments" --json
# → rowCount: 1, count: "0"
npx -y @insforge/cli db query "select pattern, enabled from realtime.channels where pattern = 'x402_payments'" --json
# → enabled: true
npx -y @insforge/cli db query "select tgname from pg_trigger where tgrelid = 'x402_payments'::regclass and not tgisinternal" --json
# → tgname: "x402_payment_realtime"
```
---
# Step 2 — Server Primitives & Env
Install deps and create three library files. These have zero coupling to your route handler; you reuse them from any endpoint you want to monetize.
```bash
npm install @insforge/sdk viem openai
```
**`src/lib/okx-facilitator.ts`** — OKX HMAC-signed calls to `/verify` and `/settle`, with a MOCK branch for local dev.
```typescript
import crypto from "crypto";
const OKX_BASE = "https://web3.okx.com/api/v6/x402";
const MOCK = process.env.MOCK_OKX_FACILITATOR === "true";
function signOKX(timestamp: string, method: string, path: string, body: string) {
return crypto
.createHmac("sha256", process.env.OKX_SECRET_KEY!)
.update(timestamp + method + path + body)
.digest("base64");
}
function okxHeaders(method: string, path: string, body: string): Record<string, string> {
const timestamp = new Date().toISOString();
return {
"OK-ACCESS-KEY": process.env.OKX_API_KEY!,
"OK-ACCESS-SIGN": signOKX(timestamp, method, path, body),
"OK-ACCESS-PASSPHRASE": process.env.OKX_PASSPHRASE!,
"OK-ACCESS-TIMESTAMP": timestamp,
"Content-Type": "application/json",
};
}
export async function verifyPayment(paymentPayload: unknown, paymentRequirements: unknown) {
if (MOCK) return { isValid: true, payer: (paymentPayload as any)?.payload?.authorization?.from };
const path = "/api/v6/x402/verify";
const body = JSON.stringify({ x402Version: 1, chainIndex: "196", paymentPayload, paymentRequirements });
const res = await fetch(OKX_BASE + "/verify", { method: "POST", headers: okxHeaders("POST", path, body), body });
const json = await res.json();
return json.data?.[0] ?? { isValid: false, invalidReason: json.msg ?? "unknown" };
}
export async function settlePayment(paymentPayload: unknown, paymentRequirements: unknown) {
if (MOCK) {
const payer = (paymentPayload as any)?.payload?.authorization?.from;
return { success: true, txHash: "0x" + crypto.randomBytes(32).toString("hex"), payer };
}
const path = "/api/v6/x402/settle";
const body = JSON.stringify({ x402Version: 1, chainIndex: "196", syncSettle: true, paymentPayload, paymentRequirements });
const res = await fetch(OKX_BASE + "/settle", { method: "POST", headers: okxHeaders("POST", path, body), body });
const json = await res.json();
return json.data?.[0] ?? { success: false, errorReason: json.msg ?? "unknown" };
}
```
**`src/lib/x402.ts`** — challenge builder, 402 response, header codecs. All wire-format concerns live here.
```typescript
const ASSET = "0x4ae46a509f6b1d9056937ba4500cb143933d2dc8"; // USDG on X Layer
export function buildPaymentRequirements(endpointUrl: string) {
return {
scheme: "exact",
maxAmountRequired: "1", // 0.000001 USDG (6 decimals, smallest unit)
resource: endpointUrl,
description: "Premium API endpoint",
mimeType: "application/json",
payTo: process.env.PAYMENT_RECIPIENT ?? "0x0000000000000000000000000000000000000000",
maxTimeoutSeconds: 300,
asset: ASSET,
extra: { name: "Global Dollar", version: "1" }, // EIP-712 domain — verify with scripts/check-domain.mjs
};
}
export function build402Response(paymentRequirements: ReturnType<typeof buildPaymentRequirements>) {
const challenge = { x402Version: 1, accepts: [{ network: "eip155:196", ...paymentRequirements }] };
return new Response(JSON.stringify({ error: "Payment required" }), {
status: 402,
headers: {
"Content-Type": "application/json",
"PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"),
},
});
}
export function decodePaymentSignature(header: string) {
return JSON.parse(Buffer.from(header, "base64").toString("utf-8"));
}
export function buildPaymentResponseHeader(settlement: { txHash: string; payer: string }) {
return Buffer.from(JSON.stringify({
success: true,
transaction: settlement.txHash,
network: "eip155:196",
payer: settlement.payer,
})).toString("base64");
}
```
**`src/lib/insforge.ts`** — one file exports both clients so you don't instantiate the SDK twice.
```typescript
import { createClient } from "@insforge/sdk";
// Server-side: full-privilege client for DB writes, AI calls, aggregate queries
export function createServiceClient() {
return createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL!,
anonKey: process.env.INSFORGE_SERVICE_KEY!, // service key goes through the anonKey slot (Bearer token)
});
}
// Browser-side: anon client for reads (RLS-protected) and realtime subscriptions
export function createBrowserClient() {
return createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL!,
anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY!,
});
}
```
**`.env`** — template with every required variable. Copy from `.env.example` and fill in.
```env
# OKX Web3 API (Step 1 of prerequisites)
OKX_API_KEY=
OKX_SECRET_KEY=
OKX_PASSPHRASE=
# Payee wallet on X Layer
PAYMENT_RECIPIENT=0x
# InsForge (dashboard → Project Settings → API Keys)
NEXT_PUBLIC_INSFORGE_URL=
NEXT_PUBLIC_INSFORGE_ANON_KEY=
INSFORGE_SERVICE_KEY=
# Model Gateway (dashboard → Model Gateway → Overview → Active OpenRouter key)
OPENROUTER_API_KEY=
# Demo mode — server skips on-chain calls, client still signs normally
MOCK_OKX_FACILITATOR=true
NEXT_PUBLIC_MOCK_OKX_FACILITATOR=true
```
**✓ Verify**
This check uses Node 20+ `--env-file` support.
```bash
node --env-file=.env -e "['OKX_API_KEY','OKX_SECRET_KEY','OKX_PASSPHRASE','PAYMENT_RECIPIENT','NEXT_PUBLIC_INSFORGE_URL','NEXT_PUBLIC_INSFORGE_ANON_KEY','INSFORGE_SERVICE_KEY','OPENROUTER_API_KEY'].forEach(k => console.log(k, process.env[k] ? 'ok' : 'MISSING'))"
# → every row 'ok'
```
---
# Step 3 — Paid Endpoint & Aggregate API
Two server routes:
1. **`POST /api/report`** — the payment-gated endpoint. Gates the response behind 402, verifies + settles, records the payment, then **generates real paid content** (an AI-written crypto market report).
2. **`GET /api/payments`** — dashboard backend. Returns recent rows + aggregate stats using the service key. The dashboard uses this for SSR / initial load so the first paint doesn't wait for the WebSocket.
**Paid content shape** — the consumer expects this JSON:
```typescript
{
report: {
title: string;
generated_at: string; // ISO timestamp
model: string; // e.g. "anthropic/claude-sonnet-4.5"
assets: Array<{ symbol: string; price: number; change_24h: number; signal: "bullish" | "bearish" | "neutral" }>;
analysis: string; // markdown
},
payment: { tx_hash: string; payer: string; amount: string }
}
```
**`src/app/api/report/route.ts`**
```typescript
import { NextRequest } from "next/server";
import OpenAI from "openai";
import { verifyPayment, settlePayment } from "@/lib/okx-facilitator";
import { createServiceClient } from "@/lib/insforge";
import {
buildPaymentRequirements,
build402Response,
decodePaymentSignature,
buildPaymentResponseHeader,
} from "@/lib/x402";
interface Asset {
symbol: string;
price: number;
change_24h: number;
signal: "bullish" | "bearish" | "neutral";
}
function generateMarketSnapshot(): Asset[] {
const rand = (min: number, max: number) => +(min + Math.random() * (max - min)).toFixed(2);
const signals: Asset["signal"][] = ["bullish", "bearish", "neutral"];
const pick = <T,>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)];
return [
{ symbol: "BTC", price: rand(83000, 86000), change_24h: rand(-3, 5), signal: pick(signals) },
{ symbol: "ETH", price: rand(1600, 1750), change_24h: rand(-4, 4), signal: pick(signals) },
{ symbol: "SOL", price: rand(125, 155), change_24h: rand(-3, 7), signal: pick(signals) },
{ symbol: "AVAX", price: rand(20, 28), change_24h: rand(-5, 6), signal: pick(signals) },
{ symbol: "LINK", price: rand(14, 18), change_24h: rand(-4, 5), signal: pick(signals) },
];
}
async function generateAIReport(assets: Asset[]): Promise<string> {
const openai = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: process.env.OPENROUTER_API_KEY,
});
const marketTable = assets
.map((a) => `- ${a.symbol}: $${a.price.toFixed(2)} (${a.change_24h > 0 ? "+" : ""}${a.change_24h}% 24h, signal: ${a.signal})`)
.join("\n");
const prompt = `You are a crypto market analyst. Write a concise professional market report in markdown based on the following 24h snapshot. Keep it under 250 words.
Snapshot:
${marketTable}
Structure your response as:
## Market Overview
2-3 sentences on overall sentiment and flow.
## Key Movers
Brief commentary on the most notable assets (1-2 lines each).
## Signal Summary
One-line actionable takeaway.
Output ONLY the markdown. No preamble, no disclaimers.`;
try {
const completion = await openai.chat.completions.create({
model: "anthropic/claude-sonnet-4.5",
messages: [{ role: "user", content: prompt }],
temperature: 0.7,
max_tokens: 600,
});
return completion.choices[0]?.message?.content ?? "_AI response was empty._";
} catch (err) {
console.error("[AI] generation failed:", err);
return "_AI service unavailable — falling back to raw data._";
}
}
export async function POST(req: NextRequest) {
const baseUrl = `${req.nextUrl.protocol}//${req.nextUrl.host}`;
const paymentRequirements = buildPaymentRequirements(`${baseUrl}/api/report`);
const paymentSigHeader = req.headers.get("PAYMENT-SIGNATURE");
if (!paymentSigHeader) return build402Response(paymentRequirements);
let paymentPayload: unknown;
try {
paymentPayload = decodePaymentSignature(paymentSigHeader);
} catch {
return Response.json({ error: "Invalid payment signature encoding" }, { status: 400 });
}
const verification = await verifyPayment(paymentPayload, paymentRequirements);
if (!verification.isValid) {
return Response.json({ error: "Payment invalid", reason: verification.invalidReason }, { status: 402 });
}
const settlement = await settlePayment(paymentPayload, paymentRequirements);
if (!settlement.success) {
return Response.json({ error: "Settlement failed", reason: settlement.errorReason }, { status: 500 });
}
// Record payment — ALWAYS check the error; settlement already moved money on-chain.
const insforge = createServiceClient();
const { error: insertError } = await insforge.database.from("x402_payments").insert([{
payer_address: settlement.payer,
endpoint: "/api/report",
amount: paymentRequirements.maxAmountRequired,
tx_hash: settlement.txHash,
status: "settled",
response_summary: "Crypto Market Analysis report",
}]);
if (insertError) console.error("[payment-log] insert failed:", insertError, "tx:", settlement.txHash);
// Generate and return paid content
const assets = generateMarketSnapshot();
const analysis = await generateAIReport(assets);
const report = {
title: "Crypto Market Analysis",
generated_at: new Date().toISOString(),
model: "anthropic/claude-sonnet-4.5",
assets,
analysis,
};
return Response.json(
{ report, payment: { tx_hash: settlement.txHash, payer: settlement.payer, amount: "0.000001 USDG" } },
{ status: 200, headers: { "PAYMENT-RESPONSE": buildPaymentResponseHeader(settlement) } }
);
}
```
**`src/app/api/payments/route.ts`** — SSR / initial load feeder for the dashboard.
```typescript
import { createServiceClient } from "@/lib/insforge";
export async function GET() {
const insforge = createServiceClient();
const { data: payments, error } = await insforge.database
.from("x402_payments")
.select()
.order("created_at", { ascending: false })
.limit(50);
if (error) return Response.json({ error: error.message }, { status: 500 });
const { data: allPayments, error: statsError } = await insforge.database
.from("x402_payments")
.select("amount");
if (statsError) return Response.json({ error: statsError.message }, { status: 500 });
const totalRequests = allPayments?.length ?? 0;
const totalRevenue = (allPayments ?? []).reduce(
(sum: number, p: { amount: string }) => sum + Number(p.amount),
0
);
return Response.json({
payments: payments ?? [],
stats: { totalRequests, totalRevenue, latestPayment: payments?.[0]?.created_at ?? null },
});
}
```
**✓ Verify** (with `MOCK_OKX_FACILITATOR=true`, server running):
```bash
# Expect 402 + PAYMENT-REQUIRED header
curl -sS -i -X POST http://localhost:3000/api/report | head -5
# Craft a fake payload, then expect 200 + paid content + DB row
PAYLOAD='{"x402Version":1,"scheme":"exact","network":"eip155:196","payload":{"signature":"0xdeadbeef","authorization":{"from":"0x1111111111111111111111111111111111111111","to":"'"$PAYMENT_RECIPIENT"'","value":"1","validAfter":"0","validBefore":"9999999999","nonce":"0x'"$(openssl rand -hex 32)"'"}}}'
SIG=$(printf '%s' "$PAYLOAD" | base64)
curl -sS -X POST -H "PAYMENT-SIGNATURE: $SIG" http://localhost:3000/api/report | head -c 400
# Aggregates
curl -sS http://localhost:3000/api/payments
# → { "payments": [...], "stats": { "totalRequests": 1, "totalRevenue": 1, ... } }
```
---
# Step 4 — Client Libs & Consumer Flow
Client-side wallet signing (`x402-client.ts`) **with complete X Layer chain switching** plus the fetch → sign → retry state machine every consumer component uses.
**`src/lib/x402-client.ts`**
```typescript
import { createWalletClient, custom, hexToBigInt, type WalletClient, type Address } from "viem";
const X_LAYER_CHAIN = {
id: 196,
name: "X Layer",
nativeCurrency: { name: "OKB", symbol: "OKB", decimals: 18 },
rpcUrls: { default: { http: ["https://rpc.xlayer.tech"] } },
} as const;
const EIP3009_TYPES = {
TransferWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" },
],
} as const;
interface PaymentChallenge {
x402Version: number;
accepts: Array<{
network: string;
scheme: string;
maxAmountRequired: string;
resource: string;
description: string;
payTo: string;
maxTimeoutSeconds: number;
asset: string;
extra: { name: string; version: string };
}>;
}
export function decodeChallenge(base64Header: string): PaymentChallenge {
return JSON.parse(atob(base64Header));
}
export async function connectWallet(): Promise<WalletClient> {
if (!window.ethereum) throw new Error("NO_WALLET");
const client = createWalletClient({ chain: X_LAYER_CHAIN, transport: custom(window.ethereum) });
await client.requestAddresses();
// Switch to X Layer if not already on it — otherwise signature verification will fail
const chainId = await client.getChainId();
if (chainId !== 196) {
try {
await window.ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: "0xc4" }], // 196 in hex
});
} catch (switchError: unknown) {
// 4902 = chain not added to wallet yet
if (
typeof switchError === "object" &&
switchError !== null &&
"code" in switchError &&
(switchError as { code: number }).code === 4902
) {
await window.ethereum.request({
method: "wallet_addEthereumChain",
params: [{
chainId: "0xc4",
chainName: "X Layer",
nativeCurrency: { name: "OKB", symbol: "OKB", decimals: 18 },
rpcUrls: ["https://rpc.xlayer.tech"],
blockExplorerUrls: ["https://www.okx.com/web3/explorer/xlayer"],
}],
});
} else {
throw switchError;
}
}
}
return client;
}
export async function signPayment(challenge: PaymentChallenge, walletClient: WalletClient): Promise<string> {
const accept = challenge.accepts[0];
if (!accept) throw new Error("Invalid challenge: accepts array is empty");
const [account] = await walletClient.getAddresses();
const nonceBytes = crypto.getRandomValues(new Uint8Array(32));
const nonce = ("0x" + Array.from(nonceBytes).map((b) => b.toString(16).padStart(2, "0")).join("")) as `0x${string}`;
const validBefore = BigInt(Math.floor(Date.now() / 1000) + accept.maxTimeoutSeconds);
const authorization = {
from: account,
to: accept.payTo as Address,
value: hexToBigInt(("0x" + BigInt(accept.maxAmountRequired).toString(16)) as `0x${string}`),
validAfter: BigInt(0),
validBefore,
nonce,
};
const signature = await walletClient.signTypedData({
account,
domain: {
name: accept.extra.name, // "Global Dollar"
version: accept.extra.version, // "1"
chainId: 196,
verifyingContract: accept.asset as Address,
},
types: EIP3009_TYPES,
primaryType: "TransferWithAuthorization",
message: authorization,
});
const paymentPayload = {
x402Version: challenge.x402Version,
scheme: accept.scheme,
network: accept.network,
payload: {
signature,
authorization: {
from: account,
to: accept.payTo,
value: accept.maxAmountRequired,
validAfter: "0",
validBefore: validBefore.toString(),
nonce,
},
},
};
return btoa(JSON.stringify(paymentPayload));
}
```
**Consumer state machine** — the pattern any "Try it" / "Pay" button implements. React-style reducer shown here; port to your framework as needed.
```typescript
type FlowState =
| { step: "idle" }
| { step: "loading" } // initial fetch
| { step: "payment_required"; challengeHeader: string; body: unknown } // got 402
| { step: "signing" } // wallet prompt open
| { step: "done"; status: number; body: unknown } // success (or non-402 error)
| { step: "error"; message: string };
// 1. Initial call — no payment header
async function tryEndpoint(path: string, setFlow: (s: FlowState) => void) {
setFlow({ step: "loading" });
const res = await fetch(path, { method: "POST" });
const body = await res.json();
if (res.status === 402) {
const challengeHeader = res.headers.get("payment-required");
if (!challengeHeader) return setFlow({ step: "error", message: "Missing challenge header" });
setFlow({ step: "payment_required", challengeHeader, body });
} else {
setFlow({ step: "done", status: res.status, body });
}
}
// 2. User confirms payment → connect wallet → sign → retry with PAYMENT-SIGNATURE
async function confirmPayment(path: string, challengeHeader: string, walletRef: { current: WalletClient | null }, setFlow: (s: FlowState) => void) {
setFlow({ step: "signing" });
try {
if (!walletRef.current) walletRef.current = await connectWallet();
const challenge = decodeChallenge(challengeHeader);
const paymentSignature = await signPayment(challenge, walletRef.current);
setFlow({ step: "loading" });
const res = await fetch(path, {
method: "POST",
headers: { "PAYMENT-SIGNATURE": paymentSignature },
});
setFlow({ step: "done", status: res.status, body: await res.json() });
} catch (err) {
walletRef.current = null; // reset on failure so next attempt re-prompts
const msg = err instanceof Error && err.message === "NO_WALLET"
? "Please install MetaMask or OKX Wallet"
: `Payment failed: ${err instanceof Error ? err.message : String(err)}`;
setFlow({ step: "error", message: msg });
}
}
```
Key details:
- Cache the `WalletClient` in a ref so the user isn't prompted to reconnect on every retry. Reset it on any failure.
- The `payment-required` header is case-insensitive on the fetch response (HTTP is case-insensitive), but send as `PAYMENT-SIGNATURE` on retries to match what the server's `req.headers.get("PAYMENT-SIGNATURE")` looks for.
- In **mock mode**, the client flow is identical — wallet still prompts for a real EIP-3009 signature. Only the server skips the on-chain calls.
**✓ Verify** — open the try-it UI (Step 5) in a browser and click the button; the wallet should prompt for a signature on X Layer, and a new row should appear in `x402_payments`.
---
# Step 5 — Realtime Dashboard
The dashboard combines three feeds into one UI:
1. **Initial data** — fetched once from `GET /api/payments` (gives stats + last 50 rows without waiting for WebSocket)
2. **Realtime stream** — subscribe to the `x402_payments` channel, listen for `INSERT_x402_payments`, merge into state
3. **Try-it playground** — the consumer state machine from Step 4, renders the paid `report` on success
**Browser SDK singleton** (avoid creating multiple WebSocket clients):
```typescript
// src/lib/insforge-browser.ts
"use client";
import { createBrowserClient } from "@/lib/insforge";
import type { InsForgeClient } from "@insforge/sdk";
let cached: InsForgeClient | null = null;
export function getBrowserClient(): InsForgeClient {
if (!cached) cached = createBrowserClient();
return cached;
}
```
**Realtime subscription pattern:**
```typescript
"use client";
import { useEffect, useState } from "react";
import { getBrowserClient } from "@/lib/insforge-browser";
type Payment = { id: string; payer_address: string; endpoint: string; amount: string; tx_hash: string; chain: string | null; status: string | null; created_at: string };
export function usePayments() {
const [payments, setPayments] = useState<Payment[]>([]);
const [stats, setStats] = useState<{ totalRequests: number; totalRevenue: number; latestPayment: string | null } | null>(null);
const insforge = getBrowserClient();
useEffect(() => {
// 1. Initial load from aggregate route (no WS latency for first paint)
fetch("/api/payments").then((r) => r.json()).then((data) => {
setPayments(data.payments ?? []);
setStats(data.stats ?? null);
});
// 2. Realtime stream
const onInsert = (payload: { new?: Payment }) => {
if (!payload?.new?.id) return;
setPayments((prev) => prev.some((p) => p.id === payload.new!.id) ? prev : [payload.new!, ...prev].slice(0, 50));
setStats((s) => s ? { ...s, totalRequests: s.totalRequests + 1, totalRevenue: s.totalRevenue + Number(payload.new!.amount), latestPayment: payload.new!.created_at } : s);
};
insforge.realtime.on("INSERT_x402_payments", onInsert);
insforge.realtime.connect()
.then(() => insforge.realtime.subscribe("x402_payments"))
.catch(console.error);
return () => {
insforge.realtime.off("INSERT_x402_payments", onInsert);
insforge.realtime.unsubscribe("x402_payments");
};
}, [insforge]);
return { payments, stats };
}
```
**UI composition** (framework-agnostic outline — see [demo source](https://github.com/InsForge/insforge-integration/tree/main/payment/okx-x402/src/components) for complete Tailwind components):
```tsx
<Dashboard>
<FlowSteps /> // 4-step visual: Request → Sign → Settle → Deliver
<ApiPlayground> // Uses Step 4 state machine
<Endpoint card + Try it button>
{flow.step === "payment_required" && <ConfirmPaymentButton />}
{flow.step === "done" && <ReportView body={flow.body} />}
</ApiPlayground>
<StatsCards stats={stats} /> // Total Requests / Total Revenue / Latest
<PaymentLog payments={payments}> // Live table, flash on insert
<tr>
<td>{timeAgo(p.created_at)}</td>
<td>{shortAddr(p.payer_address)}</td>
<td><a href={txUrl(p.chain, p.tx_hash)} target="_blank" rel="noopener noreferrer">{shortAddr(p.tx_hash)}</a></td> {/* Step 0: Explorer URLs */}
<td>{formatAmount(p.amount)}</td>
</tr>
</PaymentLog>
</Dashboard>
```
**ReportView** — renders the paid content from `POST /api/report`:
```typescript
import ReactMarkdown from "react-markdown";
export function ReportView({ body }: { body: { report: any; payment: any } }) {
const { report, payment } = body;
return (
<div>
<h2>{report.title}</h2>
<p>Generated at {report.generated_at} by {report.model}</p>
<table>
{report.assets.map((a: any) => (
<tr key={a.symbol}>
<td>{a.symbol}</td>
<td>${a.price.toFixed(2)}</td>
<td>{a.change_24h > 0 ? "+" : ""}{a.change_24h}%</td>
<td>{a.signal}</td>
</tr>
))}
</table>
<ReactMarkdown>{report.analysis}</ReactMarkdown>
<p>
Paid <strong>{payment.amount}</strong> · tx{" "}
<a href={txUrl("xlayer", payment.tx_hash)} target="_blank" rel="noopener noreferrer">{payment.tx_hash.slice(0, 10)}…</a>
</p>
</div>
);
}
```
**✓ Verify**
1. Open the dashboard — stats + recent rows should populate within one second (from `/api/payments`).
2. Click **Try it** → wallet prompts → confirm signature → response renders the AI-generated report + tx link.
3. The new row should **flash into the live log without a page refresh** (realtime trigger working).
4. Open a second browser tab with the dashboard; trigger a payment in one — the other tab should update live.
---
# Step 6 — Diagnostics & Go-Live
Before removing `MOCK_OKX_FACILITATOR=true` and accepting real payments, run both diagnostic scripts to confirm your EIP-712 domain and your USDG contract assumptions match on-chain reality. These catch 90% of first-deploy `Invalid Authority` errors.
**`scripts/check-domain.mjs`** — compute `DOMAIN_SEPARATOR` for candidate `(name, version)` pairs and compare against the on-chain value.
```javascript
import { keccak256, encodeAbiParameters, stringToBytes } from "viem";
const USDG = "0x4ae46a509f6b1d9056937ba4500cb143933d2dc8";
const CHAIN_ID = 196;
// Read from chain with check-usdg.mjs first, then paste here:
const ON_CHAIN_DOMAIN = "0x415f0706e345fcaf25d5be24c4fd7830d0054fc5742c51a0db9319c759bd3743";
const DOMAIN_TYPEHASH = keccak256(
stringToBytes("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
);
function computeDomain(name, version) {
return keccak256(
encodeAbiParameters(
[{ type: "bytes32" }, { type: "bytes32" }, { type: "bytes32" }, { type: "uint256" }, { type: "address" }],
[DOMAIN_TYPEHASH, keccak256(stringToBytes(name)), keccak256(stringToBytes(version)), BigInt(CHAIN_ID), USDG]
)
);
}
const candidates = [
["Global Dollar", "1"], ["Global Dollar", "2"], ["Global Dollar", "v1"], ["Global Dollar", "v2"],
["USDG", "1"], ["USDG", "2"],
];
for (const [name, version] of candidates) {
const hash = computeDomain(name, version);
console.log(`${hash === ON_CHAIN_DOMAIN ? "✓" : "✗"} name="${name}" version="${version}" → ${hash}`);
}
console.log("On-chain:", ON_CHAIN_DOMAIN);
```
**`scripts/check-usdg.mjs`** — read `name()`, `version()`, `DOMAIN_SEPARATOR()`, and `eip712Domain()` directly from the USDG contract. Use the output of `DOMAIN_SEPARATOR` to populate `ON_CHAIN_DOMAIN` in `check-domain.mjs`.
```javascript
import { createPublicClient, http } from "viem";
const RPCS = [
"https://rpc.xlayer.tech",
"https://xlayerrpc.okx.com",
"https://rpc.ankr.com/xlayer",
"https://xlayer-rpc.publicnode.com",
];
let client;
for (const rpc of RPCS) {
try {
const c = createPublicClient({ transport: http(rpc) });
await c.getChainId();
client = c;
console.log("Using RPC:", rpc);
break;
} catch {
console.log("Failed:", rpc);
}
}
if (!client) { console.error("No RPC reachable"); process.exit(1); }
const USDG = "0x4ae46a509f6b1d9056937ba4500cb143933d2dc8";
const abi = [
{ name: "name", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "string" }] },
{ name: "version", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "string" }] },
{ name: "DOMAIN_SEPARATOR", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "bytes32" }] },
{ name: "eip712Domain", type: "function", stateMutability: "view", inputs: [], outputs: [
{ name: "fields", type: "bytes1" }, { name: "name", type: "string" }, { name: "version", type: "string" },
{ name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" },
{ name: "salt", type: "bytes32" }, { name: "extensions", type: "uint256[]" },
]},
];
async function tryRead(fnName) {
try {
const result = await client.readContract({ address: USDG, abi, functionName: fnName });
console.log(`✓ ${fnName}:`, result);
} catch (e) {
console.log(`✗ ${fnName}:`, e.shortMessage || e.message?.slice(0, 200));
}
}
await tryRead("name");
await tryRead("version");
await tryRead("DOMAIN_SEPARATOR");
await tryRead("eip712Domain");
```
Run:
```bash
node scripts/check-usdg.mjs
# → name: "Global Dollar", version: "1", DOMAIN_SEPARATOR: 0x415f…3743
node scripts/check-domain.mjs
# → ✓ name="Global Dollar" version="1" → 0x415f…3743 (match)
```
## Go-live checklist
- [ ] `scripts/check-usdg.mjs` prints expected `name` / `version` / `DOMAIN_SEPARATOR`
- [ ] `scripts/check-domain.mjs` shows **exactly one `✓`** — the pair you have in `buildPaymentRequirements.extra`
- [ ] `MOCK_OKX_FACILITATOR` unset (or `false`) in prod env
- [ ] `NEXT_PUBLIC_MOCK_OKX_FACILITATOR` unset on client too (no misleading "mock" badge in prod)
- [ ] Paying wallet funded with USDG on X Layer (payer pays USDG; OKX facilitator covers gas)
- [ ] `PAYMENT_RECIPIENT` is a 0x-prefixed EVM address (not XKO... native format)
- [ ] Make one real payment end-to-end: wallet prompts → settles → row in `x402_payments` → tx hash resolves on `okx.com/web3/explorer/xlayer/tx/{hash}`
- [ ] Monitor `postgres.logs` and app logs for `[payment-log] insert failed` — this means money moved but the record is lost; reconcile manually
---
## Common Mistakes
| Mistake | Solution |
|---------|----------|
| ❌ Using OKX exchange trading API key | ✅ Create a separate Web3 API key at `web3.okx.com/onchainos/dev-portal` |
| ❌ EIP-712 domain `name: "USDG"` / `version: "2"` | ✅ Run `scripts/check-domain.mjs`; correct values are `name: "Global Dollar"`, `version: "1"` |
| ❌ Missing `chainIndex: "196"` on `/verify` | ✅ Both `/verify` and `/settle` require `chainIndex` (else `50014 chainIndex not empty or should be numeric`) |
| ❌ Wallet on Ethereum mainnet when user clicks pay | ✅ `connectWallet()` must call `wallet_switchEthereumChain` → fallback to `wallet_addEthereumChain` on error 4902 |
| ❌ Ignoring result of `insert(...)` after settlement | ✅ Always check `{ error }` — settlement already took money; a silent DB failure loses the record |
| ❌ `tx_hash text not null` without UNIQUE | ✅ Add `UNIQUE` to prevent duplicate records from retries |
| ❌ Hardcoding `xlayer` in explorer URL | ✅ Use `payment.chain` column + `txUrl()` helper for multi-chain support |
| ❌ `MOCK_OKX_FACILITATOR=true` in production | ✅ Demo mode only — removes on-chain guarantee; always unset in prod |
| ❌ Dashboard loads empty then pops | ✅ SSR initial data from `/api/payments` before subscribing to realtime |
| ❌ Creating multiple `createClient` instances in the browser | ✅ Singleton via `getBrowserClient()` — otherwise duplicate WebSocket connections |
| ❌ Forgetting RLS on `x402_payments` | ✅ Enable RLS + `public_read` SELECT policy; writes go through service key which bypasses RLS |
references/stytch.md›
# InsForge + Stytch Integration Guide
Stytch handles authentication via email magic links on the client side. On the server, the Stytch Node SDK validates the session cookie, retrieves the user ID, and signs a JWT with InsForge's secret. The token is passed to InsForge as `accessToken` (deprecated alias: `edgeFunctionToken`).
## Key packages
- `@stytch/nextjs` + `@stytch/vanilla-js` — Stytch frontend SDK
- `stytch` — Stytch Node SDK (server-side session validation)
- `@insforge/sdk` — InsForge client
- `jsonwebtoken` — for server-side JWT signing
## Recommended Workflow
```text
1. Configure Stytch project → Stytch Dashboard (manual)
2. Create/link InsForge project → npx -y @insforge/cli create or link
3. Install deps + configure env → npm install, .env.local
4. Create Stytch provider → app/stytch-provider.tsx (client component)
5. Create login page → app/login/page.tsx with magic links
6. Create auth callback page → app/authenticate/page.tsx (client component, NOT route handler)
7. Create InsForge client utility → lib/insforge.ts (server-side session validation + JWT signing)
8. Set up InsForge database → requesting_user_id() + table + RLS
9. Build features → CRUD pages using InsForge client
```
## Dashboard setup (manual, cannot be automated)
### Stytch Project
- In Stytch Dashboard > Redirect URLs, add `http://localhost:3000/authenticate` (Type: All)
- In Frontend SDK > Configuration, add `http://localhost:3000` as an authorized domain
- Note down **Project ID**, **Public Token**, **Secret** from Project overview > API keys
### InsForge Project
- Create via `npx -y @insforge/cli create` or link via `npx -y @insforge/cli link --project-id <id>`
- Get the JWT secret via CLI: `npx -y @insforge/cli secrets get JWT_SECRET`
- Note down **URL** and **Anon Key** from InsForge, then export the CLI value as `INSFORGE_JWT_SECRET`
## Stytch provider
- Create a `StytchProviderWrapper` **client component** at `app/stytch-provider.tsx` using `createStytchUIClient` with the public token
- Wrap the app with it in `app/layout.tsx`
```typescript
// app/stytch-provider.tsx
'use client';
import { StytchProvider, createStytchUIClient } from '@stytch/nextjs';
const stytch = createStytchUIClient(
process.env.NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN!
);
export default function StytchProviderWrapper({ children }: { children: React.ReactNode }) {
return <StytchProvider stytch={stytch}>{children}</StytchProvider>;
}
```
## Login page
- Create `app/login/page.tsx` as a client component
- Use `StytchLogin` component with `Products.emailMagicLinks`
- Configure redirect URLs to point to `/authenticate`
```typescript
// app/login/page.tsx
'use client';
import { Products, StytchLogin } from '@stytch/nextjs';
export default function Login() {
const config = {
products: [Products.emailMagicLinks],
emailMagicLinksOptions: {
loginRedirectURL: 'http://localhost:3000/authenticate',
loginExpirationMinutes: 30,
signupRedirectURL: 'http://localhost:3000/authenticate',
signupExpirationMinutes: 30,
},
};
return <StytchLogin config={config} />;
}
```
## Authentication callback
- **Must be a client-side page** at `app/authenticate/page.tsx`, NOT a route handler — Stytch SDK handles magic link tokens on the client
- Use `useStytch().magicLinks.authenticate()` to exchange the token
- Use a `useRef` to prevent double-authentication (React strict mode / re-renders)
- Check `stytch_token_type === 'magic_links'` from search params before authenticating
- Redirect to `/` on success, `/login` on failure
```typescript
// app/authenticate/page.tsx
'use client';
import { useStytch, useStytchSession } from '@stytch/nextjs';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef } from 'react';
export default function Authenticate() {
const stytch = useStytch();
const { session } = useStytchSession();
const router = useRouter();
const searchParams = useSearchParams();
const authenticating = useRef(false);
useEffect(() => {
if (session) { router.replace('/'); return; }
const token = searchParams.get('token');
const type = searchParams.get('stytch_token_type');
if (token && type === 'magic_links' && !authenticating.current) {
authenticating.current = true;
stytch.magicLinks
.authenticate(token, { session_duration_minutes: 60 })
.then(() => router.replace('/'))
.catch(() => router.replace('/login'));
}
}, [stytch, session, router, searchParams]);
return <div>Authenticating...</div>;
}
```
## InsForge client
- Create a server-side utility at `lib/insforge.ts`
- Create a Stytch `Client` instance with `project_id` and `secret` (use `envs.test` for dev)
- Read `stytch_session` from cookies via `next/headers`
- Validate the session via `stytchClient.sessions.authenticate({ session_token })`
- Sign a JWT with `jsonwebtoken` using `INSFORGE_JWT_SECRET`
- Required claims: `sub` (from `session.user_id`), `role: "authenticated"`, `aud: "insforge-api"`
- Pass the signed token as `accessToken` to `createClient`
```typescript
// lib/insforge.ts
import { createClient } from '@insforge/sdk';
import jwt from 'jsonwebtoken';
import { Client, envs } from 'stytch';
import { cookies } from 'next/headers';
const stytchClient = new Client({
project_id: process.env.STYTCH_PROJECT_ID!,
secret: process.env.STYTCH_SECRET!,
env: envs.test,
});
export async function createInsForgeClient() {
const cookieStore = await cookies();
const sessionToken = cookieStore.get('stytch_session')?.value;
if (!sessionToken) return null;
const { session } = await stytchClient.sessions.authenticate({
session_token: sessionToken,
});
const insforgeToken = jwt.sign(
{
sub: session.user_id,
role: 'authenticated',
aud: 'insforge-api',
exp: Math.floor(Date.now() / 1000) + 60 * 60,
},
process.env.INSFORGE_JWT_SECRET!
);
return createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL!,
accessToken: insforgeToken,
});
}
```
## Database setup
- Stytch user IDs are strings (e.g. `user-test-...`), not UUIDs — use `TEXT` columns for `user_id`
- Create a `requesting_user_id()` SQL function that extracts the `sub` claim from `auth.jwt()` as text
- Set `user_id` column default to `requesting_user_id()` so it auto-populates on insert
- Enable RLS and create policies that compare `user_id = requesting_user_id()`
```sql
create or replace function public.requesting_user_id()
returns text
language sql stable
as $$
select nullif(auth.jwt() ->> 'sub', '')::text
$$;
```
## Environment variables
| Variable | Source |
|----------|--------|
| `STYTCH_PROJECT_ENV` | `test` for dev |
| `STYTCH_PROJECT_ID` | Stytch Dashboard |
| `NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN` | Stytch Dashboard |
| `STYTCH_SECRET` | Stytch Dashboard |
| `NEXT_PUBLIC_INSFORGE_URL` | InsForge Dashboard |
| `NEXT_PUBLIC_INSFORGE_ANON_KEY` | InsForge Dashboard |
| `INSFORGE_JWT_SECRET` | InsForge CLI (`npx -y @insforge/cli secrets get JWT_SECRET`) |
## Common Mistakes
| Mistake | Solution |
|---------|----------|
| ❌ Making the callback a route handler | ✅ Must be a client-side page — Stytch SDK handles magic links on the client |
| ❌ Forgetting redirect URL / domain in Stytch dashboard | ✅ Add both `http://localhost:3000/authenticate` and `http://localhost:3000` |
| ❌ Not guarding against double-authentication | ✅ Use a `useRef` to prevent re-entry on re-renders |
| ❌ Using `auth.uid()` for RLS policies | ✅ Use `requesting_user_id()` — Stytch IDs are strings, not UUIDs |
references/workos.md›
# InsForge + WorkOS Integration Guide
WorkOS AuthKit handles authentication via middleware. On the server, `withAuth()` retrieves the authenticated user, and a JWT is signed with InsForge's secret containing the user's ID. The token is passed to InsForge as `accessToken` (deprecated alias: `edgeFunctionToken`).
## Key packages
- `@workos-inc/authkit-nextjs` — WorkOS AuthKit for Next.js
- `@insforge/sdk` — InsForge client
- `jsonwebtoken` + `@types/jsonwebtoken` — for server-side JWT signing
## Recommended Workflow
```text
1. Create WorkOS application → WorkOS Dashboard (manual)
2. Configure JWT template → WorkOS Dashboard (manual)
3. Create/link InsForge project → npx -y @insforge/cli create or link
4. Install deps + configure env → npm install, .env.local
5. Set up callback + login routes → app/callback/route.ts, app/login/route.ts
6. Set up middleware + layout → middleware.ts, app/layout.tsx
7. Create InsForge client utility → lib/insforge.ts (server-side JWT signing)
8. Set up InsForge database → requesting_user_id() + table + RLS
9. Build features → CRUD pages using InsForge client
```
## Dashboard setup (manual, cannot be automated)
### WorkOS Application
- Note down **API Key** and **Client ID** from WorkOS Dashboard > API Keys
- Add `http://localhost:3000/callback` under Redirects
- Enable desired auth methods (email/password, social login, SSO, etc.)
### WorkOS JWT Template
- In WorkOS Dashboard > Authentication > Sessions > Configure JWT Template
- Claims: `role: "authenticated"`, `aud: "insforge-api"`, `user_email: {{ user.email }}`
- `sub` is reserved — auto-included, do not add manually
### InsForge Project
- Create via `npx -y @insforge/cli create` or link via `npx -y @insforge/cli link --project-id <id>`
- Get the JWT secret via CLI: `npx -y @insforge/cli secrets get JWT_SECRET`
- Note down **URL** and **Anon Key** from InsForge, then export the CLI value as `INSFORGE_JWT_SECRET`
## App structure
- **Callback route**: `app/callback/route.ts` — export `handleAuth()` from `@workos-inc/authkit-nextjs`
- **Layout**: wrap with `AuthKitProvider` from `@workos-inc/authkit-nextjs/components` in `app/layout.tsx`
- **Middleware**: `middleware.ts` — export `authkitMiddleware()`, match `['/', '/api/:path*']`
- **Login route**: `app/login/route.ts` — get sign-in URL via `getSignInUrl()` and `redirect()`
**Next.js 16 limitation**: `withAuth({ ensureSignedIn: true })` can cause **cookie errors** in server components. Use `redirect('/login')` in the page instead.
```typescript
// app/callback/route.ts
import { handleAuth } from '@workos-inc/authkit-nextjs';
export const GET = handleAuth();
```
```typescript
// middleware.ts
import { authkitMiddleware } from '@workos-inc/authkit-nextjs';
export default authkitMiddleware();
export const config = { matcher: ['/', '/api/:path*'] };
```
```typescript
// app/login/route.ts
import { getSignInUrl } from '@workos-inc/authkit-nextjs';
import { redirect } from 'next/navigation';
export async function GET() {
const signInUrl = await getSignInUrl();
redirect(signInUrl);
}
```
## InsForge client
- Create a server-side utility at `lib/insforge.ts`
- Use `withAuth()` to get the WorkOS user
- Sign a JWT with `jsonwebtoken` using `INSFORGE_JWT_SECRET`
- Required claims: `sub` (from `user.id`), `role: "authenticated"`, `aud: "insforge-api"`
- Set expiration to 1 hour
- Pass the signed token as `accessToken` to `createClient`
```typescript
// lib/insforge.ts
import { createClient } from '@insforge/sdk';
import { withAuth } from '@workos-inc/authkit-nextjs';
import jwt from 'jsonwebtoken';
export async function createInsForgeClient() {
const { user } = await withAuth();
if (!user) return null;
const insforgeToken = jwt.sign(
{
sub: user.id,
role: 'authenticated',
aud: 'insforge-api',
exp: Math.floor(Date.now() / 1000) + 60 * 60,
},
process.env.INSFORGE_JWT_SECRET!
);
return createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL!,
accessToken: insforgeToken,
});
}
```
## Database setup
- WorkOS user IDs are strings (e.g. `user_01H...`), not UUIDs — use `TEXT` columns for `user_id`
- Create a `requesting_user_id()` SQL function that extracts the `sub` claim from `auth.jwt()` as text
- Set `user_id` column default to `requesting_user_id()` so it auto-populates on insert
- Enable RLS and create policies that compare `user_id = requesting_user_id()`
```sql
create or replace function public.requesting_user_id()
returns text
language sql stable
as $$
select nullif(auth.jwt() ->> 'sub', '')::text
$$;
```
## Environment variables
| Variable | Source |
|----------|--------|
| `WORKOS_API_KEY` | WorkOS Dashboard |
| `WORKOS_CLIENT_ID` | WorkOS Dashboard |
| `WORKOS_COOKIE_PASSWORD` | Generate with `openssl rand -hex 32` |
| `NEXT_PUBLIC_WORKOS_REDIRECT_URI` | `http://localhost:3000/callback` |
| `NEXT_PUBLIC_INSFORGE_URL` | InsForge Dashboard |
| `NEXT_PUBLIC_INSFORGE_ANON_KEY` | InsForge Dashboard |
| `INSFORGE_JWT_SECRET` | InsForge CLI (`npx -y @insforge/cli secrets get JWT_SECRET`) |
## Common Mistakes
| Mistake | Solution |
|---------|----------|
| ❌ Using `withAuth({ ensureSignedIn: true })` in server components | ✅ Causes cookie errors on Next.js 16 — use `redirect('/login')` instead |
| ❌ Forgetting `WORKOS_COOKIE_PASSWORD` | ✅ Session encryption fails silently without it |
| ❌ Using `auth.uid()` for RLS policies | ✅ Use `requesting_user_id()` — WorkOS IDs are strings, not UUIDs |
SKILL.md›
---
name: insforge-integrations
description: >-
Use when wiring an external auth provider (Clerk, Auth0, WorkOS, Kinde,
Stytch, Better Auth) into InsForge for JWT-based RLS, or when adding the
OKX x402 payment facilitator for onchain pay-per-use billing.
license: Apache-2.0
---
# InsForge Integrations
This skill covers integrating **third-party providers** with InsForge. Currently two categories are supported: **auth providers** (RLS via JWT claims) and **payment facilitators** (x402 HTTP payment protocol). Each provider has its own guide under this directory.
## Auth Providers
| Provider | Guide | When to use |
|----------|-------|-------------|
| [Clerk](references/clerk.md) | Clerk JWT Templates + InsForge RLS | Clerk signs tokens directly via JWT Template — no server-side signing needed |
| [Auth0](references/auth0.md) | Auth0 Actions + InsForge RLS | Auth0 uses a post-login Action to embed claims into the access token |
| [WorkOS](references/workos.md) | WorkOS AuthKit + InsForge RLS | WorkOS AuthKit middleware + server-side JWT signing with `jsonwebtoken` |
| [Kinde](references/kinde.md) | Kinde + InsForge RLS | Kinde token customization for InsForge integration |
| [Stytch](references/stytch.md) | Stytch + InsForge RLS | Stytch session tokens for InsForge integration |
| [Better Auth](references/better-auth.md) | Better Auth + InsForge RLS | Self-hosted auth running in your InsForge Postgres — no third-party SaaS, no per-MAU cost |
## Payment Facilitators
| Provider | Guide | When to use |
|----------|-------|-------------|
| [OKX x402](references/okx-x402.md) | OKX as x402 facilitator (USDG on X Layer) | Pay-per-use HTTP endpoints settled onchain with zero gas for the payer |
## Common Patterns
### Auth providers
1. **Provider signs or issues a JWT** containing the user's ID
2. **JWT is passed to InsForge** via `accessToken` in `createClient()` (deprecated alias: `edgeFunctionToken`)
3. **InsForge exposes claims** through `auth.jwt()` in SQL
4. **RLS policies** use a `requesting_user_id()` function to enforce row-level security
### Payment facilitators (x402)
1. **Server returns `402 Payment Required`** with a JSON challenge base64-encoded in `PAYMENT-REQUIRED` header
2. **Client signs an EIP-3009 authorization** using the stablecoin's EIP-712 domain
3. **Server forwards the signed payload** to the facilitator's `/verify` + `/settle` endpoints
4. **Server records the settled payment** in an InsForge table with a realtime trigger for live dashboards
## Choosing a Provider
**Auth**
- **Clerk** — Simplest setup; JWT Template handles signing, no server code needed
- **Auth0** — Flexible; uses post-login Actions for claim injection
- **WorkOS** — Enterprise-focused; AuthKit middleware + server-side JWT signing
- **Kinde** — Developer-friendly; built-in token customization
- **Stytch** — API-first; session-based token flow
- **Better Auth** — Self-hosted in your Postgres; no SaaS vendor; you own the user table. Pairs cleanly with InsForge's Postgres via a connection string + a small bridge route. Requires a one-time `REVOKE` after migrate to seal PostgREST exposure.
**Payment facilitators**
- **OKX x402** — Onchain pay-per-use via USDG on X Layer; zero gas for the payer
## Setup
1. Identify which provider the project uses
2. Read the corresponding reference guide from the tables above
3. Follow the provider-specific setup steps
## Usage Examples
Each provider guide includes full code examples for:
- Provider dashboard configuration (API keys, application settings, etc.)
- Server and client code (JWT utilities for auth; facilitator client + signing utilities for payments)
- Database setup (RLS for auth; payment table + realtime trigger for payments)
- Environment variable setup
Refer to the specific `references/<provider>.md` file for complete examples.
## Best Practices
**Auth**
- All auth provider user IDs are strings (not UUIDs) — always use `TEXT` columns for `user_id`
- Use `requesting_user_id()` instead of `auth.uid()` for RLS policies
- Pass the JWT via `accessToken` — a static string, not a function; for short-lived tokens (Clerk) sync refreshes with `client.setAccessToken(token, AuthChangeEvent.TOKEN_REFRESHED)` after the initial same-user sign-in
- Always get the JWT secret via `npx -y @insforge/cli secrets get JWT_SECRET`
**Payment facilitators (x402)**
- Always check the result of the database `insert(...)` after settlement — settlement takes money onchain before the insert runs; a silent DB failure loses the record
- Add `UNIQUE` to the `tx_hash` column to prevent duplicate records from retries
- Verify EIP-712 domain (`name`, `version`) against the token contract's on-chain `DOMAIN_SEPARATOR` — wrong values produce `Invalid Authority` errors
- Use a `MOCK_OKX_FACILITATOR` env flag for local dev so the full flow can be exercised without real funds
## Common Mistakes
**Auth**
| Mistake | Solution |
|---------|----------|
| Using `auth.uid()` for RLS | Use `requesting_user_id()` — third-party IDs are strings, not UUIDs |
| Using UUID columns for `user_id` | Use `TEXT` — all supported providers use string-format IDs |
| Hardcoding the JWT secret | Always retrieve via `npx -y @insforge/cli secrets get JWT_SECRET` |
| Missing `requesting_user_id()` function | Must be created before RLS policies will work |
**Payments (x402)**
| Mistake | Solution |
|---------|----------|
| Using an OKX exchange trading API key | Create a separate Web3 API key at `web3.okx.com/onchainos/dev-portal` |
| Wrong EIP-712 domain values | Read the token contract's `DOMAIN_SEPARATOR` — for USDG on X Layer use `name: "Global Dollar"`, `version: "1"` |
| Ignoring DB insert error after settlement | Always destructure `{ error }` and log/handle it — money has already moved |
| `MOCK_OKX_FACILITATOR=true` in production | Mock mode is demo-only; it returns fake tx hashes and bypasses verification |