SKILL DETAIL
insforge
insforge/insforge-skills/insforge
This skill covers client-side SDK integration using @insforge/sdk, including database CRUD, auth, storage uploads and RLS, function invocation, OpenRouter AI, realtime events, email sending, and Stripe or Razorpay payments. It also guides pointing S3-compatible tooling (such as aws CLI, AWS SDKs, rclone, Terraform, boto3) at InsForge Storage. The skill provides quick setup instructions, including installing the SDK, configuring environment variables (URL and anon key), and initializing the client. It includes module references and integration guides for different frameworks like Next.js, Vite, Astro, SvelteKit, Create React App, and Node.js, covering modules such as Database, Auth, Storage, Functions, AI, Realtime, Email, and Payments.
Installation
npx skills add https://github.com/insforge/insforge-skills --skill insforge
Skill files
SKILL.md
Last synced · Oct 24, 2026
agents/openai.yaml›
interface:
display_name: "InsForge"
short_description: "Build app features on InsForge: data, auth, storage, AI."
brand_color: "#17B26A"
default_prompt: "Use $insforge to add authentication and database CRUD to this app."
ai/audio.md›
# Audio
Use OpenRouter audio APIs for speech-to-text (STT) and text-to-speech (TTS).
Keep OpenRouter request details in the official docs; use this guide for how to
wire audio features into InsForge Storage, database rows, and server-side app
flows.
Official OpenRouter references:
- [Speech-to-Text](https://openrouter.ai/docs/guides/overview/multimodal/stt)
- [Text-to-Speech](https://openrouter.ai/docs/guides/overview/multimodal/tts)
- [Audio input/output with chat completions](https://openrouter.ai/docs/guides/overview/multimodal/audio)
- [Create transcription](https://openrouter.ai/docs/api/api-reference/transcriptions/create-audio-transcriptions)
- [Models API](https://openrouter.ai/docs/api-reference/models/get-models)
## Naming
Use OpenRouter's terms in user-facing docs and code comments:
| Capability | OpenRouter term | Endpoint |
|------------|-----------------|----------|
| Audio to text | Speech-to-Text (STT), transcription | `/api/v1/audio/transcriptions` |
| Text to audio | Text-to-Speech (TTS), speech | `/api/v1/audio/speech` |
| Audio reasoning | Audio input via chat completions | `/api/v1/chat/completions` |
Name the InsForge guide `audio.md` because it covers both STT and TTS.
Use "transcription" only for STT-specific tables, jobs, or UI.
## Speech-to-Text
For STT, upload the original audio file to InsForge Storage first, then
transcribe from server-side code. Store the transcript and OpenRouter metadata
in the database:
- `audio_url` and `audio_key` for the source file
- transcript text
- model ID and audio format
- duration/cost/usage when returned
- OpenRouter generation ID when available
- owner/session/tenant fields needed for RLS
OpenRouter STT expects base64-encoded raw audio bytes, not a data URI. For long
recordings, split audio into smaller segments before sending them to
OpenRouter; the official docs call out upstream timeout risk for large files.
## Text-to-Speech
For TTS, generate speech from server-side code and treat the OpenRouter response
as audio bytes. Upload the result to InsForge Storage and save:
- `audio_url` and `audio_key`
- input text or a reference to the source message
- model ID, voice, response format, and speed when used
- owner/session/tenant fields needed for RLS
Prefer `mp3` for stored playback. Use raw PCM only for realtime audio pipelines.
Do not store generated audio bytes or base64 audio in Postgres.
## Audio Input via Chat
Use the dedicated STT endpoint when the product needs a transcript. Use audio
input via chat completions when the model should reason about the audio, answer
questions about it, or combine audio with other modalities.
## Best Practices
1. Keep `OPENROUTER_API_KEY` server-side; browsers upload/listen through your
app, not directly through OpenRouter.
2. Store audio files in InsForge Storage and database rows with `url` plus `key`.
3. Store transcripts, model IDs, usage/cost, and generation IDs for auditing and
debugging.
4. Use RLS on transcript and audio-asset tables so users cannot read each
other's recordings or generated speech.
5. Use [models-list.md](models-list.md) to discover models with
`output_modalities=transcription` or `output_modalities=speech`.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Calling STT/TTS directly from the browser | Route through server-side code so the key stays private |
| Storing raw audio/base64 in Postgres | Store files in Storage; save `url` and `key` in the database |
| Calling the chat audio endpoint when a transcript is needed | Use `/api/v1/audio/transcriptions` for STT |
| Treating TTS responses as JSON | TTS returns an audio byte stream |
| Sending long recordings as one STT request | Split long audio into smaller segments |
ai/chat-completions.md›
# Chat Completions
Use OpenRouter chat completions for chatbot responses, summarization,
classification, and structured text. Use InsForge for sessions, messages,
permissions, audit history, and realtime UI updates.
Official OpenRouter references:
- [Chat completions](https://openrouter.ai/docs/api-reference/chat-completion)
- [OpenAI SDK with OpenRouter](https://openrouter.ai/docs/guides/community/openai-sdk)
- [Parameters](https://openrouter.ai/docs/api-reference/parameters)
- [Streaming](https://openrouter.ai/docs/api-reference/streaming)
## Setup
Start with [overview.md](overview.md): run `npx -y @insforge/cli ai setup`, keep
`OPENROUTER_API_KEY` server-side, and initialize the OpenAI SDK with
`baseURL: 'https://openrouter.ai/api/v1'`.
## Store a Response
For a chatbot app, keep session/message rows in InsForge and protect them with
RLS. In a server route/action, insert the user message, call OpenRouter, then
insert the assistant response. Derive ownership from the authenticated
server-side context or enforce it through the parent chat session's RLS policy;
never trust a browser-supplied `user_id`. InsForge database inserts use array
format.
```typescript
type ChatRole = 'system' | 'user' | 'assistant'
async function sendChatMessage(sessionId: string, content: string) {
const { data: userData, error: userError } = await insforge.auth.getCurrentUser()
if (userError || !userData?.user?.id) {
throw userError ?? new Error('Authentication required')
}
const history = await insforge.database
.from('chat_messages')
.select('role, content')
.eq('session_id', sessionId)
.order('created_at', { ascending: true })
if (history.error) {
throw history.error
}
const { error: userInsertError } = await insforge.database
.from('chat_messages')
.insert([{
session_id: sessionId,
user_id: userData.user.id,
role: 'user',
content,
}])
if (userInsertError) {
throw userInsertError
}
const previousMessages = (history.data ?? []).map((message: {
role: ChatRole
content: string
}) => ({
role: message.role,
content: message.content,
}))
const completion = await openai.chat.completions.create({
model: process.env.OPENROUTER_CHAT_MODEL ?? 'openai/gpt-4o',
messages: [
{ role: 'system', content: 'Answer clearly and stay on topic.' },
...previousMessages,
{ role: 'user', content },
],
max_completion_tokens: 500,
})
const answer = completion.choices[0]?.message?.content ?? ''
const { data, error: assistantInsertError } = await insforge.database
.from('chat_messages')
.insert([{
session_id: sessionId,
user_id: userData.user.id,
role: 'assistant',
content: answer,
model: completion.model,
prompt_tokens: completion.usage?.prompt_tokens,
completion_tokens: completion.usage?.completion_tokens,
}])
.select()
if (assistantInsertError) {
throw assistantInsertError
}
return data?.[0]
}
```
For streaming chat, stream tokens to the browser for UX, buffer the final
assistant text on the server, and insert one final `assistant` row when the
stream completes. Only store token-by-token deltas if the product needs replay
or detailed debugging.
## Best Practices
1. Store chat sessions and messages in InsForge, not local browser state, when
the app needs history, sync, moderation, billing, or support debugging.
2. Use RLS on chat tables; never trust a client-supplied `user_id` without
checking session ownership.
3. Save `model`, token usage, and error state on assistant rows for auditing and
cost analysis.
4. For streaming, persist the final assistant message once; avoid writing every
token unless replay is a product requirement.
5. Use InsForge Realtime to fan out newly inserted assistant messages to other
tabs/devices if the chat UI needs live sync.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Calling OpenRouter from the browser | Route through your server so the key stays private |
| Keeping chatbot history only in React state | Store sessions/messages in InsForge and secure them with RLS |
| Inserting a single object instead of an array | Use `.insert([{ ... }])` |
| Using invented or stale model IDs | Query OpenRouter models before implementing |
| Omitting token limits in user-triggered endpoints | Set `max_completion_tokens` for predictable cost |
| Treating OpenAI SDK errors like InsForge `{ data, error }` results | OpenAI SDK calls throw; catch exceptions separately |
ai/embeddings-and-rag.md›
# Embeddings and RAG
Use OpenRouter embeddings through the OpenAI SDK, then store vectors in
InsForge Postgres with pgvector. InsForge remains the database/vector store;
OpenRouter provides the embedding model gateway.
Official references:
- [OpenRouter embeddings](https://openrouter.ai/docs/api-reference/embeddings)
- [OpenAI SDK with OpenRouter](https://openrouter.ai/docs/guides/community/openai-sdk)
- [InsForge CLI pgvector setup](../../insforge-cli/references/database/vector.md)
## Setup
Bring up the `vector` extension, vector tables, indexes, and match RPCs through
an InsForge CLI database migration; see
[database/vector.md](../../insforge-cli/references/database/vector.md).
Run the AI key setup from the linked app directory:
```bash
npx -y @insforge/cli ai setup
```
Initialize the OpenAI SDK with OpenRouter as shown in [overview.md](overview.md).
Do not expose `OPENROUTER_API_KEY` to the browser. Generate embeddings through a
server route, server action, function, or backend.
## Model and Schema
Check current embedding models before shipping:
```bash
curl https://openrouter.ai/api/v1/embeddings/models \
-H "Authorization: Bearer $OPENROUTER_API_KEY"
```
Common dimensions:
| Model | Dimensions |
|-------|------------|
| `openai/text-embedding-3-small` | 1536 |
| `openai/text-embedding-3-large` | 3072 |
| `google/gemini-embedding-001` | 3072 |
The Postgres column must match the embedding dimension, for example
`embedding vector(1536)`. Store the embedding model next to ingested content if
you expect to re-embed or migrate later.
## Store Documents
Use InsForge for the durable content row and vector. Keep one embedding model
per vector column.
```typescript
async function storeDocument(content: string) {
const response = await openai.embeddings.create({
model: process.env.OPENROUTER_EMBEDDING_MODEL ?? 'openai/text-embedding-3-small',
input: content,
})
const { data, error } = await insforge.database.from('documents').insert([{
content,
embedding: response.data[0].embedding,
}]).select()
if (error) {
throw error
}
return data
}
```
For batch ingestion, pass an array to `input` and insert rows in the same order
as `response.data`.
## Search and Answer
Embed the user query, retrieve matching rows through the `match_documents` RPC,
then pass retrieved content into a chat completion. Store the final answer in
your app's own table if the product has conversation history.
```typescript
async function askQuestion(sessionId: string, question: string) {
const embeddingResponse = await openai.embeddings.create({
model: process.env.OPENROUTER_EMBEDDING_MODEL ?? 'openai/text-embedding-3-small',
input: question,
})
const { data: documents, error: searchError } = await insforge.database.rpc('match_documents', {
query_embedding: embeddingResponse.data[0].embedding,
match_count: 5,
match_threshold: 0.78,
})
if (searchError) {
throw searchError
}
const context = (documents ?? [])
.map((doc: { content: string }) => doc.content)
.join('\n\n')
const completion = await openai.chat.completions.create({
model: process.env.OPENROUTER_CHAT_MODEL ?? 'openai/gpt-4o',
messages: [
{ role: 'system', content: `Answer using this context:\n\n${context}` },
{ role: 'user', content: question },
],
})
const answer = completion.choices[0]?.message?.content ?? ''
const { error: insertError } = await insforge.database.from('chat_messages').insert([
{ session_id: sessionId, role: 'user', content: question },
{
session_id: sessionId,
role: 'assistant',
content: answer,
model: completion.model,
retrieval_count: documents?.length ?? 0,
},
])
if (insertError) {
throw insertError
}
return answer
}
```
## Best Practices
1. Design the InsForge tables first: source documents, vector column dimension,
match RPC, and optional chat history table.
2. If your schema includes an `embedding_model` column, store the model ID with
embedded rows so future migrations can identify which vectors need
re-embedding.
3. Use one embedding model per vector column. Re-embed when changing models.
4. Add chunking, query rewriting, re-ranking, context truncation, and retrieval
evaluation before treating a RAG prototype as production-ready.
5. Use RLS on source documents and chat history. If a `SECURITY DEFINER` RPC
bypasses RLS, re-filter inside the function by `auth.uid()` or tenant ID.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Asking the user to enable an embedding model in old AI Settings | Use OpenRouter embedding models and `OPENROUTER_API_KEY` |
| Querying `ai.configs` for embedding models | Use `/api/v1/embeddings/models` |
| Putting `OPENROUTER_API_KEY` in browser env vars | Keep it server-side |
| Column dimension does not match model dimension | Match `vector(N)` to the model output |
| `encoding_format: 'base64'` into pgvector | Use float arrays |
| Mixing embedding models in one column | Pick one; mixed vectors give meaningless search results |
ai/image-generation.md›
# Image Generation
Use OpenRouter image-capable chat models for image generation or image editing,
then store durable outputs in InsForge Storage.
Official OpenRouter references:
- [Image generation](https://openrouter.ai/docs/guides/overview/multimodal/image-generation)
- [Chat completions](https://openrouter.ai/docs/api-reference/chat-completion)
- [Models API](https://openrouter.ai/docs/api-reference/models/get-models)
## Setup
Start with [overview.md](overview.md): run `npx -y @insforge/cli ai setup` and keep
`OPENROUTER_API_KEY` server-side. Check image-capable models before use:
```bash
curl "https://openrouter.ai/api/v1/models?output_modalities=image" \
-H "Authorization: Bearer $OPENROUTER_API_KEY"
```
## Generate an Image
OpenRouter image generation uses chat completions with image output
modalities. Direct `fetch` avoids TypeScript friction around OpenRouter-specific
fields such as `modalities`, `image_config`, and `message.images`.
```typescript
const prompt = 'Create a clean product mockup on a white desk.'
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: process.env.OPENROUTER_IMAGE_MODEL ?? 'google/gemini-2.5-flash-image',
modalities: ['image', 'text'],
messages: [
{ role: 'user', content: prompt },
],
image_config: {
aspect_ratio: '16:9',
},
}),
})
if (!response.ok) {
throw new Error(`OpenRouter image request failed: ${response.status}`)
}
const result = await response.json()
const imageUrl = result.choices?.[0]?.message?.images?.[0]?.image_url?.url
if (!imageUrl) {
throw new Error('OpenRouter response did not include an image URL')
}
```
## Store the Output
OpenRouter output URLs should be treated as transfer URLs, not your app's
permanent asset store. Upload the generated file to InsForge Storage and save
both `url` and `key` in the database.
Do not store raw base64 image data or large binary blobs in Postgres. Store
metadata and Storage references in the database; keep image bytes in Storage.
Derive owner, tenant, or session fields from the authenticated server-side
context; never accept ownership IDs from the browser.
```typescript
const imageResponse = await fetch(imageUrl)
const imageBlob = await imageResponse.blob()
const storageKey = `generated/${crypto.randomUUID()}.png`
const { data, error } = await insforge.storage
.from('images')
.upload(storageKey, imageBlob)
if (error) {
throw error
}
const { data: userData, error: userError } = await insforge.auth.getCurrentUser()
if (userError || !userData?.user?.id) {
throw userError ?? new Error('Authentication required')
}
await insforge.database.from('generated_images').insert([{
user_id: userData.user.id,
prompt,
image_url: data.url,
image_key: data.key,
}])
```
## Best Practices
1. Use [models-list.md](models-list.md) to verify `output_modalities` includes
`image`.
2. Keep image prompts and model calls on the server.
3. Persist generated images in InsForge Storage before storing database rows.
4. Save the original prompt, model ID, storage `url`, and storage `key` when the
product needs reproducibility or deletion.
5. Use authenticated owner, session, or tenant fields on generated-image rows so
RLS can restrict access.
6. Use direct HTTP when OpenAI SDK types reject OpenRouter-specific image
fields; do not remove those fields to satisfy TypeScript.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Using a text-only model for image output | Filter models with `output_modalities=image` |
| Storing raw base64 image data or temporary image URLs in Postgres | Upload to Storage, save `url` and `key` |
| Removing `modalities` because TypeScript complains | Use direct `fetch` or a narrow local type cast |
| Assuming every image model supports the same options | Check the model and image generation docs first |
ai/models-list.md›
# Models List
Use OpenRouter model discovery before choosing model IDs, capabilities, pricing,
or modality-specific parameters.
Official OpenRouter references:
- [Models API](https://openrouter.ai/docs/api-reference/models/get-models)
- [Model routing](https://openrouter.ai/docs/features/model-routing)
- [Parameters](https://openrouter.ai/docs/api-reference/parameters)
## Setup
Start with [overview.md](overview.md): run `npx -y @insforge/cli ai setup` so
`OPENROUTER_API_KEY` is available server-side.
## List Models
```bash
curl https://openrouter.ai/api/v1/models \
-H "Authorization: Bearer $OPENROUTER_API_KEY"
```
Use the returned model metadata to verify:
| Field | Why it matters |
|-------|----------------|
| `id` | Exact string to pass as `model` |
| `input_modalities` | Whether the model accepts text, image, audio, or other inputs |
| `output_modalities` | Whether the model can generate text, image, audio, or video |
| `context_length` | How much prompt/context you can send |
| `pricing` | Cost estimates for user-facing or batch workflows |
| `supported_parameters` | Which request parameters the model accepts |
## Capability Filters
```bash
# Image-output chat models
curl "https://openrouter.ai/api/v1/models?output_modalities=image" \
-H "Authorization: Bearer $OPENROUTER_API_KEY"
# Embedding models
curl https://openrouter.ai/api/v1/embeddings/models \
-H "Authorization: Bearer $OPENROUTER_API_KEY"
# Video models
curl https://openrouter.ai/api/v1/videos/models \
-H "Authorization: Bearer $OPENROUTER_API_KEY"
# Speech-to-text models
curl "https://openrouter.ai/api/v1/models?output_modalities=transcription" \
-H "Authorization: Bearer $OPENROUTER_API_KEY"
# Text-to-speech models
curl "https://openrouter.ai/api/v1/models?output_modalities=speech" \
-H "Authorization: Bearer $OPENROUTER_API_KEY"
```
The InsForge dashboard Model Gateway model list is also suitable for browsing
model IDs and checking whether the project's OpenRouter key is active.
## Best Practices
1. Do not guess model IDs. Query OpenRouter or use the dashboard model list.
2. Check modalities before using image, video, audio, speech, transcription, or embedding code paths.
3. Check `supported_parameters` before sending advanced options.
4. Keep production model IDs in env/config when product owners may tune them.
5. For embeddings, record model ID and dimension next to the vector schema.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Using old `ai.configs` rows as the source of truth | Use OpenRouter model discovery |
| Assuming one model supports all modalities | Check `input_modalities` and `output_modalities` |
| Hard-coding "latest" model assumptions into docs | Query models at implementation time |
| Swapping embedding models without schema planning | Confirm dimension and re-embed existing rows |
ai/overview.md›
# AI Overview
Use OpenRouter as the model gateway for new InsForge AI features. InsForge
provides the project-level OpenRouter key; app code should call OpenRouter from
server-side code and use InsForge for data, storage, auth, and RLS.
Official OpenRouter references:
- [OpenAI SDK with OpenRouter](https://openrouter.ai/docs/guides/community/openai-sdk)
- [Chat completions](https://openrouter.ai/docs/api-reference/chat-completion)
- [Image generation](https://openrouter.ai/docs/guides/overview/multimodal/image-generation)
- [Embeddings](https://openrouter.ai/docs/api-reference/embeddings)
- [Video generation](https://openrouter.ai/docs/guides/overview/multimodal/video-generation)
- [Models API](https://openrouter.ai/docs/api-reference/models/get-models)
## Setup
Run this from the linked app directory before adding AI code:
```bash
npx -y @insforge/cli ai setup
```
This fetches the active OpenRouter key from the linked InsForge backend and
writes it to `.env.local`:
```bash
OPENROUTER_API_KEY=sk-or-v1-...
```
For non-standard env files, use `--env-file <path>`. If the command is
unavailable, ask the user to upgrade `@insforge/cli` or copy the key from the
InsForge dashboard Model Gateway.
Keep `OPENROUTER_API_KEY` server-side only. Browser-visible env prefixes such
as `NEXT_PUBLIC_*`, `VITE_*`, `PUBLIC_*`, and `REACT_APP_*` are for non-secret
values.
Use these optional server-side model override names consistently when a project
needs configurable models:
| Variable | Used For |
|----------|----------|
| `OPENROUTER_CHAT_MODEL` | Chat completions and RAG answer generation |
| `OPENROUTER_IMAGE_MODEL` | Image generation |
| `OPENROUTER_EMBEDDING_MODEL` | Embeddings and vector search |
Install the OpenAI SDK when using chat completions or embeddings:
```bash
npm install openai
```
```typescript
import OpenAI from 'openai'
export const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: process.env.OPENROUTER_API_KEY,
})
```
For browser apps, create a server route, server action, function, or backend
endpoint that calls OpenRouter. Have the browser call your server endpoint.
## Capability Guides
| Capability | Use |
|------------|-----|
| [chat-completions.md](chat-completions.md) | Text generation, structured answers, streaming chat |
| [image-generation.md](image-generation.md) | Generate or edit images, then upload outputs to InsForge Storage |
| [video-generation.md](video-generation.md) | Async video jobs, server-side polling, and storing generated media |
| [audio.md](audio.md) | Speech-to-text, text-to-speech, and audio asset storage patterns |
| [embeddings-and-rag.md](embeddings-and-rag.md) | Generate embeddings, store/search vectors in InsForge pgvector, and build RAG |
| [models-list.md](models-list.md) | Discover model IDs, modalities, pricing, limits, and dimensions |
## InsForge Patterns
1. Run `npx -y @insforge/cli ai setup` before writing OpenRouter code.
2. Store generated files in InsForge Storage; save both `url` and `key` in the
database.
3. Store embeddings in Postgres `vector(N)` columns; `N` must match the model's
output dimension.
4. Use `@insforge/sdk` for database, auth, storage, realtime, and payments.
Use OpenRouter APIs for model calls.
5. Check model capabilities through OpenRouter before using image, video,
embeddings, or special parameters.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Putting `OPENROUTER_API_KEY` in public frontend env vars | Keep it server-side and proxy through your app backend |
| Querying old `ai.configs` tables for supported models | Use [models-list.md](models-list.md) and OpenRouter model APIs |
| Using `insforge.ai` for new features | Use OpenRouter APIs with the project OpenRouter key |
| Saving generated base64/media directly in Postgres | Upload to InsForge Storage and save `url` plus `key` |
ai/video-generation.md›
# Video Generation
Use OpenRouter video models for asynchronous generation jobs. Keep this guide
high level: rely on OpenRouter docs for request/response details, and use
InsForge for job tracking, scheduled polling, Storage, and frontend status.
Official OpenRouter references:
- [Video generation](https://openrouter.ai/docs/guides/overview/multimodal/video-generation)
- [Submit a video generation request](https://openrouter.ai/docs/api/api-reference/video-generation/create-videos)
- [Poll video generation status](https://openrouter.ai/docs/api/api-reference/video-generation/get-videos)
- [List video models](https://openrouter.ai/docs/api/api-reference/video-generation/list-videos-models)
- [Models API](https://openrouter.ai/docs/api-reference/models/get-models)
## Recommended InsForge Flow
1. Start the OpenRouter video job from server-side code only.
2. Store a `video_jobs` row in InsForge with the requesting user, prompt, model,
OpenRouter job ID, polling URL, status, retry metadata, and storage fields.
3. Poll OpenRouter from server-side infrastructure, such as an InsForge Edge
Function triggered by an InsForge Schedule.
4. Keep each scheduled invocation bounded. Control how many jobs it polls and
how many completed videos it downloads/uploads in one run.
5. When a job completes, download the video server-side with
`OPENROUTER_API_KEY`, upload it to InsForge Storage, and save `video_url` and
`video_key`.
6. Let the frontend read `video_jobs` from InsForge or subscribe via Realtime.
The browser should never poll OpenRouter or see `OPENROUTER_API_KEY`.
Use a DB **lease** for in-flight work, not a permanent lock. `lease_owner` plus
`lease_expires_at` communicates that a crashed poller can be retried after the
lease expires.
## Deno Memory
Downloading a video in an Edge Function can buffer the entire MP4 in memory,
depending on the upload path. That can be fine for short, low-resolution clips
when you process only one video per schedule run. For larger videos, use a
Compute worker or a streaming/multipart upload path instead of buffering the
whole file inside Deno.
## Best Practices
1. Use server-side polling as the default baseline.
2. Keep each scheduled invocation bounded with explicit limits and backoff.
3. Store generated videos in InsForge Storage; save both `url` and `key` in the
database.
4. Store prompt, model, duration, resolution, status, polling URL, errors, retry
metadata, and Storage references for support/debugging.
5. Use OpenRouter model docs for exact parameters and supported durations,
sizes, and input formats.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Treating video generation as synchronous | Store a job row and poll from a scheduled server-side function |
| Letting one cron invocation drain the whole queue | Bound each poller run with explicit limits and backoff |
| Polling OpenRouter from the browser | Poll from an InsForge Edge Function or server worker |
| Saving only the OpenRouter content URL | Download server-side, upload to Storage, save `url` and `key` |
| Assuming `unsigned_urls[0]` is a public app URL | Treat it as a server-side download URL and send Authorization |
| Buffering large videos in Deno Edge Functions | Use short clips or move large outputs to Compute/streaming upload |
auth/sdk-integration.md›
# Authentication SDK Integration
User authentication, registration, and session management via `insforge.auth`.
> **Package choice**: Use `@insforge/sdk` directly for authentication flows. Build auth UI components with the SDK methods documented below.
> **Auth email delivery is built in.** Signup verification, password reset, magic links, and invites ship on **every plan** (free included) through the InsForge platform. For custom transactional email, see [email/sdk-integration.md](../email/sdk-integration.md).
## Setup
First, ensure your `.env` file is configured with your InsForge URL and anon key. Get the anon key with `npx -y @insforge/cli secrets get ANON_KEY`. See the main [SKILL.md](../SKILL.md) for framework-specific variable names and full setup steps.
```javascript
import { createClient } from '@insforge/sdk'
const insforge = createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL, // adjust prefix for your framework
anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY // adjust prefix for your framework
})
```
## SSR / Server-Rendered Apps
For Next.js, Remix, SvelteKit, Nuxt server routes, or any other SSR setup, use `@insforge/sdk/ssr` helpers. See [ssr-integration.md](ssr-integration.md) for the full pattern and minimal examples.
## Sign Up (Complete Flow)
Registration may require email verification. Implement the flow based on backend config.
1. **Sign up** — Create the user account
2. **If verification is required** — Branch on `verifyEmailMethod`
3. **Complete the verification flow**
- `code`: user enters the 6-digit code and your app calls `verifyEmail()`
- `link`: backend verifies the emailed link first, then redirects to your app via `redirectTo`
> **Important**: For link-based verification, pass `redirectTo` to `signUp()`. Recommended: use your sign-in page as `redirectTo`, then show a success message and ask the user to sign in with their email and password.
```javascript
try {
// Step 1: Register the user
const { data, error } = await insforge.auth.signUp({
email: '[email protected]',
password: 'securepassword123',
name: 'John Doe',
redirectTo: 'http://localhost:3000/sign-in'
})
if (error) throw error
if (data?.requireEmailVerification) {
// Code method:
// - Show a 6-digit code input on the same page
// - Call verifyEmail({ email, otp })
//
// Link method:
// - Show "Check your email"
// - Recommended redirectTo: your sign-in page
// - On redirect success, show a confirmation message and ask the user to sign in
} else if (data?.accessToken) {
// No verification required — user is already signed in
console.log('Signed in:', data.user)
}
} catch (error) {
console.error('Registration flow failed:', error.message)
}
```
### Resend Verification Email
```javascript
try {
await insforge.auth.resendVerificationEmail({
email: '[email protected]',
redirectTo: 'http://localhost:3000/sign-in'
})
console.log('Verification email resent.')
} catch (error) {
console.error('Failed to resend:', error.message)
}
```
## Sign In
```javascript
const { data, error } = await insforge.auth.signInWithPassword({
email: '[email protected]',
password: 'securepassword123'
})
if (error) {
console.error('Sign in failed:', error.message)
if (error.statusCode === 403) {
console.error('Email not verified. Redirect to verification page.')
}
} else {
console.log('Signed in:', data.user.email)
}
```
## Passwordless Sign In (Email OTP)
> Requires `@insforge/sdk` 1.5.1+.
Two steps: request a code, then verify it. `verifyOtp()` returns `{ data: { user, accessToken }, error }` and **automatically saves the session** — the user is signed in on success. When signups are enabled a brand-new email becomes a verified, passwordless user (with `disableSignup` on, `verifyOtp()` rejects unknown emails with a 403); `name` sets the display name only when the account is first created.
```javascript
// 1. Request the 6-digit code. The response is generic whether or not an
// account exists (prevents account enumeration) — always show the same
// "check your email" state.
const { error: sendError } = await insforge.auth.signInWithOtp({
email: '[email protected]'
})
if (sendError) console.error('Could not send code:', sendError.message)
// 2. Verify the code the user typed in.
const { data, error } = await insforge.auth.verifyOtp({
email: '[email protected]',
otp: '123456',
name: 'Ada Lovelace' // optional; applied only when the account is first created
})
if (error) {
console.error('Invalid or expired code:', error.message)
} else {
console.log('Signed in:', data.user.email)
}
```
> The code expires in 5 minutes, is single-use, and is invalidated after 3 failed attempts. A wrong entry can be retried with the same code; once it expires or the three attempts are used up, have the user request a fresh one with `signInWithOtp()`.
## OAuth Sign In
OAuth uses PKCE. The SDK handles code generation, redirect, and token exchange automatically in the browser.
### Two redirect URLs
| URL | Points to | Where to configure |
|-----|-----------|-------------------|
| OAuth provider callback | InsForge backend (`https://<project>.insforge.app/api/auth/oauth/<provider>/callback`) | Google Console, GitHub OAuth app, etc. |
| `redirectTo` | **Your app** (`https://yourapp.com/auth/callback`) | Passed in `signInWithOAuth()` |
`redirectTo` is where the user lands after auth. The backend appends `?insforge_code=<code>` to it. Set it to a page where your app initializes the SDK or handles the server callback.
### SPA (browser) — fully automatic
```javascript
await insforge.auth.signInWithOAuth('google', {
redirectTo: 'http://localhost:3000/dashboard', // any page where SDK is initialized
additionalParams: { prompt: 'select_account' } // optional provider-specific params
})
```
Use `additionalParams` only for provider-specific optional hints. Do not pass server-owned OAuth fields such as `client_id`, `scope`, `redirect_uri`, `code_challenge`, `state`, or `response_type`; InsForge sets those server-side and ignores colliding client-provided keys.
The SDK constructor auto-detects `insforge_code` in the URL, exchanges it for a session, and cleans the URL. Initialize the SDK on the `redirectTo` page.
### SSR (Next.js) — manual exchange required
The browser auto-detection is for SPA flows. In SSR apps, use `skipBrowserRedirect: true` and exchange the OAuth code in a Route Handler so the refresh token can be written as an httpOnly cookie. See [ssr-integration.md](ssr-integration.md) for the full implementation.
```javascript
const { data } = await insforge.auth.signInWithOAuth('google', {
redirectTo: 'https://yourapp.com/api/auth/callback',
skipBrowserRedirect: true
})
// data.codeVerifier — store in httpOnly cookie before redirect
// data.url — redirect user to this
```
### SDK methods reference
| Method | When to use |
|--------|-------------|
| `signInWithOAuth(provider, { redirectTo, additionalParams? })` | SPA: auto-redirects and handles everything |
| `signInWithOAuth(provider, { redirectTo, additionalParams?, skipBrowserRedirect: true })` | SSR: returns `{ url, codeVerifier }` for manual handling |
| `exchangeOAuthCode(code, codeVerifier?)` | Exchange `insforge_code` for session. Auto-called in SPA; call manually in SSR |
## Sign Out
```javascript
const { error } = await insforge.auth.signOut()
```
## Get Current User
```javascript
const { data, error } = await insforge.auth.getCurrentUser()
if (data.user) {
console.log('User:', data.user.email)
}
```
For browser apps, call `getCurrentUser()` during startup. The SDK will use the httpOnly refresh cookie automatically when it can refresh the session.
For SSR apps, use `createRefreshAuthRouter()` / `refreshAuth()` from `@insforge/sdk/ssr` to refresh through your app route.
### Cold loads & external redirects
In SPA browser apps using the root `@insforge/sdk` client, the access token is stored in memory only. On a cold page load, `getCurrentUser()` starts with no in-memory access token, so the SDK rehydrates the session by calling the backend refresh endpoint with the httpOnly refresh cookie and the JS-readable `insforge_csrf_token` cookie/header flow. During that network round-trip, `user` is temporarily `null`.
In SSR browser clients created with `createBrowserClient()` from `@insforge/sdk/ssr`, the access token is read from the `insforge_access_token` cookie and refreshed through your app's `/api/auth/refresh` route.
Any auth wrapper or hook should expose both `user` and `loading`:
```tsx
import { createContext, useContext, useEffect, useState } from 'react'
const AuthContext = createContext({ user: null, loading: true })
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function hydrateAuth() {
const { data, error } = await insforge.auth.getCurrentUser()
if (cancelled) return
setUser(error ? null : (data?.user ?? null))
setLoading(false)
}
void hydrateAuth()
return () => {
cancelled = true
}
}, [])
return (
<AuthContext.Provider value={{ user, loading }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
return useContext(AuthContext)
}
```
When auth state controls visible UI, gate the logged-in vs logged-out branch on `loading`:
```tsx
function Layout() {
const { user, loading } = useAuth()
return (
<header>
{loading ? <div className="auth-skeleton" /> : user ? <AccountMenu /> : <SignInButton />}
</header>
)
}
```
This matters most when the user lands back in your app after an external redirect:
- Post-OAuth callback
- Stripe Checkout success or cancel URL
- Stripe Customer Portal return URL
- Password-reset link landing
- Email-verification link landing
### Gate user-dependent side effects during auth loading
If a mount-time effect branches on the current user, guard the user-dependent work until `loading === false`. This is especially important for code paths that do one thing for signed-in users and another for guests.
```tsx
const [shouldRunAction, setShouldRunAction] = useState(false)
const handled = useRef(false)
const userId = user?.id ?? null
useEffect(() => {
const handleStatusChanged = ({ id, status }) => {
if (id === resourceId && status === 'ready') {
setShouldRunAction(true)
}
}
insforge.realtime.on('status_changed', handleStatusChanged)
return () => insforge.realtime.off('status_changed', handleStatusChanged)
}, [resourceId])
useEffect(() => {
if (loading || !shouldRunAction || handled.current) return
async function runUserDependentAction() {
await performUserDependentAction({ userId })
handled.current = true
}
void runUserDependentAction()
}, [loading, shouldRunAction, userId])
```
Webhook-backed Realtime flows can complete before the cold-load auth refresh finishes, especially after Stripe Checkout, Customer Portal, OAuth, password-reset, or email-verification redirects. If you use a `cleared.current` or other "first event wins" guard, flip it after `loading === false` and the user-dependent work has actually succeeded.
## Profile Management
In SQL triggers and migrations, user profile metadata lives in
`auth.users.profile` JSONB. Read common values with `NEW.profile->>'name'` and
`NEW.profile->>'avatar_url'`.
```javascript
// Get any user's public profile
const { data } = await insforge.auth.getProfile('user-id')
// Update current user's profile
const { data } = await insforge.auth.setProfile({
name: 'John',
avatar_url: 'https://...',
custom_field: 'value'
})
```
## Email Verification
`verifyEmail()` returns `{ data: { user, accessToken }, error }` and **automatically saves the session** — the user is signed in after successful verification.
```javascript
// Verify with code (6-digit OTP from email)
const { data, error } = await insforge.auth.verifyEmail({
email: '[email protected]',
otp: '123456'
})
if (error) {
if (error.statusCode === 400) {
console.error('Invalid or expired code')
}
} else {
// User is now verified AND signed in
console.log('Signed in:', data.user)
}
// Resend verification email
await insforge.auth.resendVerificationEmail({
email: '[email protected]',
redirectTo: 'http://localhost:3000/sign-in'
})
```
### Link Verification Flow
Use `redirectTo` for link-based verification. Recommended: use your sign-in page.
Your frontend should handle these redirect query params:
- `insforge_status`: `success` or `error`
- `insforge_type`: always `verify_email`
- `insforge_error`: present only on error
When `insforge_status=success`, show a confirmation message and ask the user to sign in with their email and password.
## Password Reset
```javascript
// Step 1: Send reset email
await insforge.auth.sendResetPasswordEmail({
email: '[email protected]',
redirectTo: 'http://localhost:3000/reset-password'
})
// Step 2: Code method — exchange code for token
const { data } = await insforge.auth.exchangeResetPasswordToken({
email: '[email protected]',
code: '123456'
})
// Step 3: Reset password
await insforge.auth.resetPassword({
newPassword: 'newPassword123',
otp: data.token // or token from magic link
})
```
### Link Reset Flow
Use `redirectTo` for link-based reset. Recommended: use your app's dedicated reset-password page.
Your frontend should handle these redirect query params:
- `token`: present only when the reset form should be shown
- `insforge_status`: `ready` or `error`
- `insforge_type`: always `reset_password`
- `insforge_error`: present only on error
Only render the reset form when `insforge_status=ready` and `token` is present.
## Important Notes
- **SPA Web vs Mobile**: Root browser SDK flows use httpOnly refresh cookies + CSRF; mobile/desktop returns refreshToken in response
- **SSR apps should use SDK SSR helpers**: For Next.js and similar SSR frameworks, use `@insforge/sdk/ssr` for client creation, refresh routes, and auth cookies; use `@insforge/sdk/ssr/middleware` for Proxy/Middleware session updates. See [ssr-integration.md](ssr-integration.md)
- All methods return `{ data, error }` — always check for errors
- OAuth uses PKCE flow for security
---
## Best Practices
1. **Always check auth config first** before implementing
- Run `npx -y @insforge/cli metadata --json` to get auth config (`requireEmailVerification`, `verifyEmailMethod`, `resetPasswordMethod`, `oAuthProviders`, `allowedRedirectUrls`)
- This tells you what features to implement
- To change supported project config such as redirect URLs, verification flags, password policy, or auth SMTP settings, use `npx -y @insforge/cli config apply` — see the **insforge-cli** skill's Configuration section. OAuth providers and external app setup are dashboard/provider-managed.
2. **The sign-up page must handle the full registration flow**
- After calling `signUp()`, if `requireEmailVerification` is true, branch on `verifyEmailMethod`
- For `"code"`, switch the UI to show a 6-digit code input on the **same page**
- For `"link"`, pass `redirectTo` to `signUp()` and show a "check your email" state
- Keep the user in the verification flow until verification is completed
- Recommended verification `redirectTo`: your sign-in page
- `verifyEmail()` automatically saves the session only for the code flow
3. **Render OAuth from configured providers**
- Check `oAuthProviders` array in config
- The array contains enabled provider names (e.g., `["google", "github"]`)
4. **Handle the sign-up response correctly**
```javascript
const { data, error } = await insforge.auth.signUp({...})
if (error) {
// Show error message to user
} else if (data?.requireEmailVerification) {
// Usually: switch UI to show 6-digit code input and keep the user in the verification flow
// If verifyEmailMethod === "link", show a "check your email" state instead
} else if (data?.accessToken) {
// No verification needed — user is signed in, navigate to app
}
```
5. **Use `@insforge/sdk/ssr` for SSR auth**
- For Next.js or other SSR frameworks, perform auth mutations where cookies can be written
- Use `createAuthActions()` for sign-in, sign-up, sign-out, OAuth initiation/exchange, ID-token sign-in, and email verification flows that create or clear sessions
- Keep `insforge_refresh_token` httpOnly and server-owned
- Let `insforge_access_token` be browser-readable so Storage and Realtime can authenticate from Client Components
- Use `createServerClient()` for Server Components / Route Handlers and `createBrowserClient()` for Client Components; the SSR browser client exposes read-only auth methods only
- Add `/api/auth/refresh` with `createRefreshAuthRouter()` and use `updateSession()` from `@insforge/sdk/ssr/middleware` in Proxy/Middleware
- Use [ssr-integration.md](ssr-integration.md) as the reference implementation
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Navigating to dashboard/home while verification is still required | Stay in the verification flow and branch on `verifyEmailMethod` |
| Skipping the email verification step | Check `requireEmailVerification` in the sign-up response and implement the verification step |
| Missing `redirectTo` for link flows | Pass the app URL as `redirectTo` and include it in `allowedRedirectUrls` |
| Building the wrong verification UI | Build the UI from `verifyEmailMethod` |
| Treating link verification like code verification | Handle the redirect result and send the user to sign in |
| Signing in again after code-based `verifyEmail()` | Use the session returned by `verifyEmail()` |
| Hardcoding OAuth providers | Render providers from `oAuthProviders` |
| Handling SSR auth like a browser-only flow | Use `createAuthActions()` for auth mutations, `createBrowserClient()` only to consume an existing SSR session, and `@insforge/sdk/ssr/middleware` for Proxy/Middleware session updates |
| Passing `apiKey` to `createClient()` | Use `createAdminClient({ apiKey })` in trusted server-only code |
## Conditional Implementation Guide
### Email Verification Flow
```javascript
// After sign-up, check if verification is needed
if (data?.requireEmailVerification) {
// If verifyEmailMethod === "code":
// Show 6-digit code input on the SAME page, then call:
const { data: verifyData, error } = await insforge.auth.verifyEmail({ email, otp: userEnteredCode })
// On success, user is automatically signed in — navigate to the app
// If verifyEmailMethod === "link":
// Pass redirectTo to signUp() / resendVerificationEmail()
// Show "Check your email and click the verification link" message
// Recommended redirectTo: your sign-in page
// On redirect success, show a confirmation message and ask the user to sign in
}
```
### OAuth Implementation
```javascript
// oAuthProviders is already an array of enabled provider names
// e.g., ["google", "github"]
const enabledProviders = authConfig.oAuthProviders
// Show OAuth buttons from enabled providers:
if (enabledProviders.includes('google')) {
// Show Google login button
}
if (enabledProviders.includes('github')) {
// Show GitHub login button
}
```
## Recommended Workflow
```
1. Get auth config → npx -y @insforge/cli metadata --json
2. Check what's enabled → Email verification? Which OAuth providers?
3. Build appropriate UI → Code input vs magic link, OAuth buttons
4. Implement sign-up → Handle requireEmailVerification response
5. Implement verification → Code input or redirectTo-based link flow
6. Implement OAuth → Use providers from oAuthProviders
7. Implement password reset → Based on resetPasswordMethod (code vs link)
```
## Implementation Checklist
Based on auth config, implement:
- [ ] Sign up form with password (respecting `passwordMinLength`)
- [ ] Email verification step on the sign-up page (if `requireEmailVerification` is true)
- [ ] 6-digit code input (if `verifyEmailMethod` is "code")
- [ ] "Check your email" state plus sign-in-page `redirectTo` handling (if `verifyEmailMethod` is "link")
- [ ] Sign in form
- [ ] OAuth buttons from enabled providers
- [ ] Password reset flow
- [ ] Code input (if `resetPasswordMethod` is "code")
- [ ] App reset page using `redirectTo` (if `resetPasswordMethod` is "link")
- [ ] Sign out
auth/ssr-integration.md›
# SSR Authentication Integration
Use this reference for Next.js SSR auth. The same cookie/session concepts can be adapted to Remix, SvelteKit, Nuxt, or other SSR frameworks, but the examples below use Next.js route handlers, server actions, and Proxy/Middleware.
## Recommended Pattern
- Use `@insforge/sdk/ssr` as the standard SSR auth entrypoint for browser,
server, refresh-route, and cookie helpers.
- Use `@insforge/sdk/ssr/middleware` for Proxy/Middleware `updateSession()`;
this keeps the middleware bundle from pulling in the full SDK client.
- Let the SDK helpers manage the InsForge auth cookie names and expiration.
- Keep `insforge_refresh_token` httpOnly and server-owned.
- Allow `insforge_access_token` to be browser-readable so browser SDK calls and Realtime can authenticate.
- Use `/api/auth/refresh` as the app refresh endpoint unless the user already has a clear project convention.
- Use Proxy/Middleware to refresh before Server Components render.
Default cookies:
| Cookie | Visibility | Purpose |
|--------|------------|---------|
| `insforge_access_token` | `httpOnly: false` | Short-lived bearer token for Server Components, Client Components, Storage, and Realtime |
| `insforge_refresh_token` | `httpOnly: true` | Long-lived server-owned refresh credential |
Both cookies should expire at the JWT `exp`; the SDK helpers do this when tokens include `exp`.
`insforge_access_token` is intentionally readable by JavaScript so browser SDK calls, Storage, and Realtime can authenticate directly. Keep access-token TTL short. If a project requires fully httpOnly auth tokens, proxy Storage and Realtime calls through server-side routes instead of direct browser SDK calls.
## Environment Variables
For Next.js, prefer:
```bash
NEXT_PUBLIC_INSFORGE_URL=https://your-project.insforge.app
NEXT_PUBLIC_INSFORGE_ANON_KEY=...
NEXT_PUBLIC_APP_URL=https://your-app.example
```
The SSR helpers use explicit `baseUrl` / `anonKey` when provided. Otherwise they read `NEXT_PUBLIC_INSFORGE_URL` / `NEXT_PUBLIC_INSFORGE_ANON_KEY` in both browser and server code. `NEXT_PUBLIC_APP_URL` is the public app origin used by OAuth redirect examples. Missing config throws a clear error.
## Browser Client
Use this in Client Components and browser-only modules:
```typescript
// app/lib/insforge/client.ts
import { createBrowserClient } from '@insforge/sdk/ssr'
export const insforge = createBrowserClient()
```
`createBrowserClient()` reads `insforge_access_token`, uses it for SDK calls and Realtime, and refreshes through `/api/auth/refresh` when the access token is missing, expired, near expiry, or rejected with an auth-expired response. Its TypeScript auth surface is read-only (`getCurrentUser()`, `getProfile()`, and `getPublicAuthConfig()`); perform sign-in, sign-up, sign-out, OAuth initiation/exchange, ID-token sign-in, email verification, and email OTP sign-in on the server with `createAuthActions()`.
## Server Client
Use this in Server Components, Route Handlers, and Server Actions:
```typescript
// app/lib/insforge/server.ts
import { cookies } from 'next/headers'
import { createServerClient } from '@insforge/sdk/ssr'
export async function createInsForgeServerClient() {
return createServerClient({
cookies: await cookies()
})
}
```
`createServerClient()` reads the access-token cookie and passes it as the per-request bearer token. The refresh token remains server-owned.
## Refresh Route
Create the default app refresh endpoint:
```typescript
// app/api/auth/refresh/route.ts
import { createRefreshAuthRouter } from '@insforge/sdk/ssr'
export const { POST } = createRefreshAuthRouter()
```
If the app needs custom side effects, use the lower-level helper:
```typescript
// app/api/auth/refresh/route.ts
import { refreshAuth } from '@insforge/sdk/ssr'
export async function POST(request: Request) {
const result = await refreshAuth({ request })
// Optional: app-specific logging, telemetry, redirect validation, etc.
return result.response
}
```
## Next.js Proxy / Middleware
Use `updateSession()` so Server Components see fresh cookies before rendering. Next.js 16 uses `proxy.ts`; Next.js 15 and earlier use `middleware.ts`.
```typescript
// proxy.ts on Next.js 16+
// middleware.ts on Next.js 15 and earlier
import { NextResponse, type NextRequest } from 'next/server'
import { updateSession } from '@insforge/sdk/ssr/middleware'
export async function proxy(request: NextRequest) {
const response = NextResponse.next({ request })
await updateSession({
requestCookies: request.cookies,
responseCookies: response.cookies
})
return response
}
```
Import `updateSession()` from `@insforge/sdk/ssr/middleware` in Proxy/Middleware
files. Keep the full `@insforge/sdk/ssr` entrypoint for `createBrowserClient()`,
`createServerClient()`, `createRefreshAuthRouter()`, `refreshAuth()`, and cookie
helpers in route handlers or server/client modules.
For `middleware.ts`, export the same handler body as `middleware`.
## Sign-In Route Or Server Action
Because the refresh token is httpOnly, sign-in, sign-up, sign-out, OAuth initiation/exchange, ID-token sign-in, email verification, and email OTP sign-in (`verifyOtp()`) flows that establish or clear a session should run where cookies can be written. Prefer `createAuthActions()` for these auth mutations. Return only safe app data from Server Actions; do not return token-bearing low-level auth responses.
For Next.js 14+ Server Actions:
```typescript
// app/actions.ts
'use server'
import { cookies } from 'next/headers'
import { createAuthActions } from '@insforge/sdk/ssr'
export async function signIn(formData: FormData) {
const auth = createAuthActions({ cookies: await cookies() })
const { data, error } = await auth.signInWithPassword({
email: String(formData.get('email')),
password: String(formData.get('password'))
})
return { user: data?.user ?? null, error }
}
export async function signOut() {
const auth = createAuthActions({ cookies: await cookies() })
return auth.signOut()
}
```
For Route Handlers, pass separate request and response cookie stores:
```typescript
// app/api/auth/sign-in/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { createAuthActions } from '@insforge/sdk/ssr'
export async function POST(request: NextRequest) {
const response = NextResponse.json({ ok: true })
const auth = createAuthActions({
requestCookies: request.cookies,
responseCookies: response.cookies
})
const { data, error } = await auth.signInWithPassword(await request.json())
if (error || !data?.user) {
return NextResponse.json(
{ error: error?.error ?? 'AUTH_UNAUTHORIZED', message: error?.message ?? 'Sign in failed' },
{ status: error?.statusCode ?? 401 }
)
}
return NextResponse.json({ user: data.user }, { headers: response.headers })
}
```
`createAuthActions()` wraps `createServerClient()` and writes or clears `insforge_access_token` / `insforge_refresh_token` using the cookie stores you pass. Keep refresh separate: `/api/auth/refresh` should still use `createRefreshAuthRouter()` or `refreshAuth()`.
## OAuth In Next.js
The browser SDK auto-detects `insforge_code` for SPA flows. In SSR apps, handle OAuth on the server so the refresh token lands in an httpOnly cookie.
### Step 1: Start OAuth
```typescript
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { createAuthActions } from '@insforge/sdk/ssr'
export async function initiateOAuth(provider: string) {
const cookieStore = await cookies()
const auth = createAuthActions({ cookies: cookieStore })
const { data, error } = await auth.signInWithOAuth(provider, {
redirectTo: new URL('/api/auth/callback', process.env.NEXT_PUBLIC_APP_URL).toString(),
// additionalParams: { prompt: 'select_account' }, // optional provider-specific hints
skipBrowserRedirect: true
})
if (error || !data.url || !data.codeVerifier) {
throw new Error(error?.message ?? 'OAuth init failed')
}
cookieStore.set('insforge_code_verifier', data.codeVerifier, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 600
})
redirect(data.url)
}
```
Use `additionalParams` only for provider-specific optional hints. Do not pass server-owned OAuth fields such as `client_id`, `scope`, `redirect_uri`, `code_challenge`, `state`, or `response_type`; InsForge sets those server-side and ignores colliding client-provided keys.
Set `redirectTo` to your app URL. The backend appends `?insforge_code=<code>` and redirects there.
### Step 2: Handle Callback
```typescript
// app/api/auth/callback/route.ts
import { cookies } from 'next/headers'
import { NextResponse, type NextRequest } from 'next/server'
import { createAuthActions } from '@insforge/sdk/ssr'
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get('insforge_code')
const oauthError = request.nextUrl.searchParams.get('error')
if (oauthError || !code) {
if (oauthError) {
console.warn('OAuth callback failed', { error: oauthError })
}
return NextResponse.redirect(new URL('/login?error=oauth_failed', request.url))
}
const cookieStore = await cookies()
const codeVerifier = cookieStore.get('insforge_code_verifier')?.value
if (!codeVerifier) {
return NextResponse.redirect(new URL('/login?error=missing_verifier', request.url))
}
const response = NextResponse.redirect(new URL('/dashboard', request.url))
const auth = createAuthActions({
requestCookies: request.cookies,
responseCookies: response.cookies
})
const { data, error } = await auth.exchangeOAuthCode(code, codeVerifier)
if (error || !data?.user) {
if (error) {
console.error('OAuth code exchange failed', error)
}
return NextResponse.redirect(new URL('/login?error=exchange_failed', request.url))
}
response.cookies.delete('insforge_code_verifier')
return response
}
```
## Storage And Realtime
For browser uploads, downloads, and Realtime subscriptions, use `createBrowserClient()`. Keep the refresh token server-owned and route browser refresh through `/api/auth/refresh`. When the access token expires, the browser client receives a fresh access token and updates the SDK with token-refresh semantics, so active Realtime sockets stay connected and the fresh JWT is used on the next handshake. Do not call auth mutations from Client Components; use `createAuthActions()` on the server.
For server-mediated uploads, use a backend route to create a signed upload path or otherwise proxy the operation. Use direct browser SDK upload when the app wants user-scoped Storage/RLS checks and the browser has the access token cookie.
## Refresh Best Practices
- Keep the default refresh path `/api/auth/refresh` unless the app has an existing auth namespace.
- Use `createRefreshAuthRouter()` for standard apps.
- Use `refreshAuth()` only when the route needs custom side effects.
- Use `updateSession()` from `@insforge/sdk/ssr/middleware` in Proxy/Middleware to keep Server Components and browser cookies aligned without bundling the full SDK client.
- Validate post-auth redirects and only allow safe internal paths.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Creating separate cookie names across routes | Let `@insforge/sdk/ssr` helpers manage the standard auth cookie names |
| Hiding the access token from Client Components | Keep `insforge_refresh_token` httpOnly and `insforge_access_token` browser-readable |
| Missing the browser refresh route | Use `/api/auth/refresh` with `createRefreshAuthRouter()` |
| Manually guessing cookie lifetimes | Use `createAuthActions()` or `setAuthCookies()` so cookie expiry follows JWT `exp` |
| Calling auth mutations from Client Components | Use `createAuthActions()` in Server Actions or Route Handlers |
| Server Components reading stale cookies | Use `updateSession()` from `@insforge/sdk/ssr/middleware` in Proxy/Middleware before rendering |
| Client Components creating an unauthenticated SDK client | Use `createBrowserClient()` so the app refresh route can refresh access |
| Sending OAuth users back to the backend URL | Set `redirectTo` to the app URL where the user lands after auth |
| Exchanging OAuth codes in a Client Component | Initiate and exchange OAuth on the server, then set cookies |
database/sdk-integration.md›
# Database SDK Integration
Use InsForge SDK to perform CRUD operations in your frontend application.
## Setup
First, ensure your `.env` file is configured with your InsForge URL and anon key. Get the anon key with `npx -y @insforge/cli secrets get ANON_KEY`. See the main [SKILL.md](../SKILL.md) for framework-specific variable names and full setup steps.
```javascript
import { createClient } from '@insforge/sdk'
const insforge = createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL, // adjust prefix for your framework
anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY // adjust prefix for your framework
})
```
For trusted server-only database work that needs project-admin access:
```javascript
import { createAdminClient } from '@insforge/sdk'
const admin = createAdminClient({
baseUrl: process.env.INSFORGE_URL,
apiKey: process.env.INSFORGE_API_KEY
})
```
## CRUD Operations
### Select
```javascript
// Specific columns (default choice — smaller responses, less bandwidth)
const { data, error } = await insforge.database.from('posts').select('id, title')
// All columns — reserve for single-record detail views; bound list reads with .limit()
const { data } = await insforge.database
.from('posts')
.select()
.order('created_at', { ascending: false })
.order('id', { ascending: false }) // tie-breaker keeps page membership stable
.limit(50)
// With relationships
const { data } = await insforge.database.from('posts').select('*, comments(id, content)')
```
Default to naming columns and bounding list reads. An unbounded `select()` re-downloads the whole table on every call — see [Bandwidth-efficient reads](#bandwidth-efficient-reads).
### Insert
```javascript
// Single record (MUST use array format)
const { data, error } = await insforge.database
.from('posts')
.insert([{ title: 'Hello', content: 'World' }])
.select()
// Bulk insert
const { data } = await insforge.database
.from('posts')
.insert([{ title: 'A' }, { title: 'B' }])
.select()
```
When a table has RLS enabled, `.insert(...).select()` behaves like
`INSERT ... RETURNING`: the new row must pass both the `INSERT` policy and the
`SELECT` policy. If `WITH CHECK` looks correct but the call still fails with
`new row violates row-level security policy`, make sure the returned row is
also visible to the caller.
### Update
```javascript
const { data, error } = await insforge.database
.from('posts')
.update({ title: 'Updated' })
.eq('id', postId)
.select()
```
### Delete
```javascript
const { error } = await insforge.database
.from('posts')
.delete()
.eq('id', postId)
```
### RPC (Stored Procedures)
```javascript
const { data, error } = await insforge.database.rpc('get_user_stats', { user_id: '123' })
```
## Selecting a Schema
Queries target the `public` schema by default — reach for `.schema()` only when you need a different one. The table name stays bare; `.schema()` maps to PostgREST's `Accept-Profile` (reads) / `Content-Profile` (writes) headers and chains in front of `from()` and `rpc()`:
```javascript
const { data, error } = await insforge.database
.schema('analytics')
.from('events')
.select('id, name, occurred_at')
// Writes chain the same way
const { error: insertError } = await insforge.database
.schema('analytics')
.from('events')
.insert([{ name: 'signup' }])
const { data: rollup, error: rpcError } = await insforge.database
.schema('analytics')
.rpc('rollup', { day: '2026-01-01' })
```
To make every query use one schema, set the default when you create the client (omit it to stay on `public`):
```javascript
const insforge = createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL,
anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY,
db: { schema: 'analytics' },
})
```
**You must grant access yourself.** On v2.2.3+ backends every non-internal schema you create is automatically reachable over the data API, but a new schema's tables stay **unreadable to `anon`/`authenticated` until you grant them** — exposure is not access. Grant in the same migration that creates the table, then RLS filters rows exactly as in `public`:
```sql
GRANT USAGE ON SCHEMA analytics TO anon, authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA analytics TO anon, authenticated;
```
**Don't target InsForge-internal schemas.** Platform schemas (`auth`, `storage`, `system`, `payments`, …) are never exposed over the data API — keep app data in `public` or your own schemas so you never collide with them. Pointing `.schema()` at an internal schema (or any schema an older backend doesn't expose) fails with PostgREST `PGRST106` ("The schema must be one of the following: ...") rather than silently falling back to `public`.
## Filters
| Filter | Example |
|--------|---------|
| `.eq(col, val)` | `.eq('status', 'active')` |
| `.neq(col, val)` | `.neq('status', 'deleted')` |
| `.gt(col, val)` | `.gt('age', 18)` |
| `.gte(col, val)` | `.gte('price', 100)` |
| `.lt(col, val)` | `.lt('stock', 10)` |
| `.lte(col, val)` | `.lte('score', 50)` |
| `.like(col, pattern)` | `.like('name', '%Widget%')` |
| `.ilike(col, pattern)` | `.ilike('email', '%@gmail.com')` |
| `.in(col, array)` | `.in('status', ['pending', 'active'])` |
| `.is(col, val)` | `.is('deleted_at', null)` |
## Modifiers
| Modifier | Example |
|----------|---------|
| `.order(col, opts)` | `.order('created_at', { ascending: false })` |
| `.limit(n)` | `.limit(10)` |
| `.range(from, to)` | `.range(0, 9)` |
| `.single()` | Returns object, throws if multiple |
| `.maybeSingle()` | Returns object or null |
## Pagination
```javascript
const page = 1, pageSize = 10
const from = (page - 1) * pageSize
const to = from + pageSize - 1
const { data, count } = await insforge.database
.from('posts')
.select('id, title, created_at', { count: 'exact' })
.range(from, to)
.order('created_at', { ascending: false })
```
## Bandwidth-efficient reads
Every byte a query returns counts against the project's monthly egress allowance. The most common way projects exhaust it is not traffic volume — it is a small number of wasteful query shapes, usually written once and left running:
- **Never poll with unbounded selects.** A dashboard or hook that re-runs `select()` (all columns, no limit) on an interval re-downloads the entire table every cycle. A table with a few hundred rows of JSONB polled every 30 seconds costs ~1 GB/day with a single viewer. To watch for changes, subscribe with [realtime](../realtime/sdk-integration.md) instead of polling — realtime is channel pub/sub, so pair the subscription with a database trigger that calls `realtime.publish(...)` on writes (backend setup: [insforge-cli realtime reference](../../insforge-cli/references/realtime.md)); table changes do not stream automatically. If you must poll, poll something cheap — `select('updated_at', { count: 'exact' }).order('updated_at', { ascending: false }).limit(1)` — and fetch full rows only when the count or newest `updated_at` moves: the count catches inserts and deletes, the timestamp catches edits (keep a `system.update_updated_at()` trigger on the table so edits advance it).
- **Bound every list read.** Always chain `.limit(n)` or `.range(from, to)` on queries that can return multiple rows. Render lists with [pagination](#pagination).
- **Name your columns.** `select('id, title')` instead of `select()` whenever the table has text/JSONB columns the view doesn't render. Fetch heavy columns (`draft_data`-style JSONB blobs, long text) only on the detail screen that needs them.
- **Pause refresh loops when idle.** If the UI auto-refreshes, stop the timer when the tab is hidden (`document.visibilityState`) — an admin page left open overnight is the classic silent egress burner.
- **Let storage objects cache.** Serve files with stable URLs so browsers and the CDN cache them, rather than re-downloading per render (cache-busting query params on every request defeat this).
## Important Notes
- **Insert requires array format**: Always use `insert([{...}])` not `insert({...})`
- **Avoid large JSON/JSONB payloads in SDK reads/writes**: PostgREST can consume excessive memory when rows contain multi-megabyte JSONB payloads. As a rule of thumb, treat JSONB around 1 MB or larger per row as a caution point for hot SDK paths, and normalize multi-megabyte or frequently accessed JSON into typed columns or child tables.
- **Select only the columns you need**: If a table has any large text/JSONB columns, avoid `select('*')` in list views. Fetch lightweight columns first, then lazy-load fields over ~1 MB on a detail screen or through a purpose-built RPC.
- All methods return `{ data, error }` - always check for errors
---
## InsForge SQL References
When creating tables via `insforge db query` (CLI), use these built-in references:
| Reference | Description |
|-----------|-------------|
| `auth.uid()` | Returns current authenticated user's UUID |
| `auth.users(id)` | Reference to the built-in users table for foreign keys |
| `auth.users.profile` | JSONB profile metadata; use `profile->>'name'` / `profile->>'avatar_url'` in auth triggers |
| `system.update_updated_at()` | Built-in trigger function that auto-updates `updated_at` columns |
### Complete Example: Table with RLS and Triggers
```sql
-- Create table with user ownership
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL DEFAULT auth.uid() REFERENCES auth.users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Enable RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Policies (see the insforge-cli database access-control reference for advanced patterns)
CREATE POLICY "users_can_insert_own_posts" ON posts
FOR INSERT TO authenticated
WITH CHECK (user_id = auth.uid());
-- Needed when app code uses .insert(...).select()
CREATE POLICY "users_can_read_own_posts" ON posts
FOR SELECT TO authenticated
USING (user_id = auth.uid());
CREATE POLICY "users_can_update_own_posts" ON posts
FOR UPDATE TO authenticated
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
CREATE POLICY "users_can_delete_own_posts" ON posts
FOR DELETE TO authenticated
USING (user_id = auth.uid());
-- Allow authenticated SDK callers to reach the table; RLS still filters rows
GRANT USAGE ON SCHEMA public TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON posts TO authenticated;
-- Auto-update updated_at on every UPDATE
CREATE TRIGGER posts_updated_at
BEFORE UPDATE ON posts
FOR EACH ROW
EXECUTE FUNCTION system.update_updated_at();
```
> For access-control best practices (RLS, infinite recursion prevention, SECURITY DEFINER, performance), see the **insforge-cli** skill's [database/access-control.md](../../insforge-cli/references/database/access-control.md) reference.
### Bulk Upsert (HTTP API)
Import CSV or JSON files directly into a table. No CLI equivalent yet — use the HTTP API.
```http
POST /api/database/advance/bulk-upsert
Authorization: Bearer {admin-token-or-api-key}
Content-Type: multipart/form-data
Fields:
- file: CSV or JSON file (required)
- table: Target table name (required)
- upsertKey: Column for conflict resolution (optional)
```
| Parameter | Effect |
|-----------|--------|
| Without `upsertKey` | INSERT all records |
| With `upsertKey` | UPSERT — update existing rows on conflict, insert new ones |
---
## Best Practices
1. **Generate TypeScript interfaces for every table schema**
- Use `insforge db tables` and `insforge db query` (CLI) to inspect the table schema
- Create a corresponding TypeScript interface/type for type safety
- This helps catch errors at compile time and improves developer experience
2. **Normalize large JSONB data before building CRUD flows**
- Do not store large app state, document bodies, analytics payloads, or arrays of nested objects in a single JSONB column that the app reads/writes through PostgREST.
- Prefer real columns for fields you filter, sort, display in lists, or update independently.
- Move repeated nested objects into child tables with foreign keys and indexes.
- Measure suspicious payloads with `pg_column_size(jsonb_column)` and enforce limits or warnings during ingestion.
### Example: Generate Interface from Schema
```typescript
// After checking table schema via `insforge db tables`
// Create a typed interface:
interface Post {
id: string
user_id: string
title: string
content: string | null
created_at: string
updated_at: string
}
// Cast data to the interface after select
const { data, error } = await insforge.database
.from('posts')
.select()
const posts = data as Post[]
```
## Recommended Workflow
```
1. Check table schema → insforge db tables / insforge db query
2. Check for large JSONB fields and normalize them if needed
3. Generate TypeScript interface for the table
4. Cast query results to the interface for type safety
5. Handle errors appropriately
```
email/sdk-integration.md›
# Email SDK Integration
Send custom transactional HTML emails via `insforge.emails.send`. Routes through AWS SES under per-project tenant identities, with automatic List-Unsubscribe headers and unsubscribe filtering.
> **🛑 No SMTP, no third-party packages.** `insforge.emails.send()` works on every paid project — no `nodemailer` / `resend` / `sendgrid` / `mailgun` / `postmark`, no `SMTP_HOST`, no provider API keys. The platform manages the SES sender. Server-only sends still authenticate with your own project API key (see below) — that is InsForge auth, not an email-provider credential. Custom sender domain → dashboard, not `package.json`.
>
> **Scope.** This module sends **custom** transactional emails (welcome, receipt, newsletter, alerts). For auth flows (signup verification, password reset, invites), use `insforge.auth.*` — those ship on **every plan**, also no SMTP.
> **⚠️ Private preview.** Custom email is in private preview. The API may change; pin to a tested SDK version. Custom email requires a **paid plan** — free-tier projects can only use the built-in auth emails.
## Setup
Ensure your `.env` is configured with your InsForge URL and anon key. Get the anon key with `npx -y @insforge/cli secrets get ANON_KEY`. See the main [SKILL.md](../SKILL.md) for framework-specific variable names.
```javascript
import { createClient } from '@insforge/sdk'
const insforge = createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL, // adjust prefix for your framework
anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY // adjust prefix for your framework
})
```
`emails.send` requires an authenticated caller — **the anon key is not sufficient.** A signed-out client sends its anon key as the bearer credential, and the route rejects that with `401 Sending emails requires an authenticated user`. Sign the user in first: the SDK swaps the anon key for that user's JWT on the same client, and the call then succeeds.
For server-only code with no signed-in user, authenticate as project admin instead:
```javascript
import { createAdminClient } from '@insforge/sdk'
const admin = createAdminClient({
baseUrl: process.env.INSFORGE_URL,
apiKey: process.env.INSFORGE_API_KEY
})
```
`admin.emails.send()` takes the same options as the examples below. Get the key with `npx -y @insforge/cli secrets get API_KEY` and keep it server-side — it is a full-access admin key, never a client bundle. See [SKILL.md](../SKILL.md).
## Send an Email
```javascript
// emails.send uses whatever credential the client is holding, so the user
// must be signed in first. Any flow in ../auth/sdk-integration.md works;
// substitute the credentials your own sign-in form collects.
const { error: signInError } = await insforge.auth.signInWithPassword({
email: '[email protected]',
password: 'securepassword123',
});
if (signInError) throw signInError;
const { data, error } = await insforge.emails.send({
to: '[email protected]',
subject: 'Welcome to Acme',
html: '<h1>Hi!</h1><p>Thanks for joining Acme.</p>',
from: 'Acme Updates', // optional display name
replyTo: '[email protected]', // optional
});
if (error) {
console.error('Email failed:', error.message);
return;
}
console.log('Sent:', data?.id);
```
## Send to Multiple Recipients
```javascript
const { data, error } = await insforge.emails.send({
to: ['[email protected]', '[email protected]'],
cc: '[email protected]',
bcc: ['[email protected]'],
subject: 'April update',
html: '<h1>This month at Acme</h1><p>...</p>',
from: 'Acme Updates',
});
// data.skipped lists any recipients that have unsubscribed and were silently dropped
if (data?.skipped?.length) {
console.log('Skipped (unsubscribed):', data.skipped);
}
```
## API Reference
```typescript
emails.send(options: {
to: string | string[]; // required, 1–50 addresses
subject: string; // required, 1–500 chars
html: string; // required, HTML body
cc?: string | string[]; // optional
bcc?: string | string[]; // optional
from?: string; // optional display name only (NOT email address)
replyTo?: string; // optional email address
}): Promise<{
data: { id: string; skipped?: string[] } | null;
error: Error | null;
}>
```
### Sender Address Rules
- **`from` is a display name only.** The actual sending address is always `noreply@<appkey>.send.insforge.dev` (your project's SES tenant subdomain). You cannot send from `[email protected]`.
- Use `replyTo` to route replies to a real inbox you control.
- The backend appends a per-recipient `List-Unsubscribe` footer and headers automatically — do not add your own.
### Limits
| Limit | Value |
|---|---|
| Max recipients per request | 50 (across `to` + `cc` + `bcc`) |
| Hourly send rate | 10–50 emails depending on plan |
| Subject length | 1–500 chars |
| Plan requirement | Paid plan (free tier returns `On-demand email service is only available for paid plans`) |
Each address in `to`/`cc`/`bcc` counts as one delivery (per SES billing) — a single send to 30 addresses is 30 emails against your quota.
## REST Fallback
If you can't use the SDK (e.g. cURL, server-side calls without the JS SDK), authenticate with a project admin API key (`ik_…`) or a signed-in user's JWT — the anon key is rejected here for the same reason it is rejected in the SDK:
```bash
curl -X POST "$INSFORGE_URL/api/email/send-raw" \
-H "Authorization: Bearer $INSFORGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"subject": "Welcome",
"html": "<h1>Hello</h1>",
"from": "Acme",
"replyTo": "[email protected]"
}'
```
Success: `HTTP 200` with body `{}` (REST endpoint omits the wrapping; SDK adds `{ data, error }`).
## Common Errors
| Error message | Cause | Fix |
|---|---|---|
| `On-demand email service is only available for paid plans` | Free tier project | Upgrade billing in the InsForge dashboard |
| `Rate limit exceeded. Try again in N minutes` | Hourly send cap reached | Wait `rateLimit.resetIn` seconds and retry, or upgrade plan |
| `Invalid ARN provided` | Backend infra config issue (missing `AWS_ACCOUNT_ID` env on the cloud backend) | Not a client bug — file with InsForge support |
| `Sending emails requires an authenticated user` (`401 AUTH_INVALID_CREDENTIALS`) | Called with the anon key — no user is signed in | Sign the user in and retry on the same client (the SDK sends their JWT), or call server-side with a project admin API key. Re-checking `anonKey` will not fix this |
| `401 Unauthorized` | Missing, malformed, or expired `Authorization` header | Confirm the header is sent; if a user JWT has expired, refresh the session and retry |
| Email lands in spam | Cold SES tenant reputation | Warm up with a few low-volume sends; use a recognizable display name; avoid spammy HTML and link-heavy bodies |
## Common Mistakes
- **Installing `nodemailer` / `resend` / `sendgrid` / `mailgun` / `postmark` or asking for SMTP credentials.** The built-in service is already wired in — `insforge.emails.send()` is all you need.
- **Trying to set `from` to an email address.** Only the display-name part is honored. The address is fixed.
- **Adding your own unsubscribe link.** The backend already injects one — adding another duplicates UX and confuses ESPs.
- **Treating skipped recipients as failures.** `data.skipped` recipients have unsubscribed; the request still returns success.
- **Sending one big request to 30 unrelated users without per-recipient personalization.** The backend already sends individual messages internally for personalized unsubscribe links — you just pass the array.
- **Calling from the free tier and reporting an "API bug".** Check `subscriptionPlan` first; the rejection is by design.
## Quick Reference
| Task | Code |
|------|------|
| Send to one recipient | `insforge.emails.send({ to, subject, html })` |
| Send to many | `insforge.emails.send({ to: [a, b], cc, bcc, subject, html })` |
| Set sender display name | `from: 'Acme Updates'` |
| Route replies | `replyTo: '[email protected]'` |
| Inspect skipped (unsubscribed) | `data.skipped` |
| REST endpoint | `POST {baseUrl}/api/email/send-raw` |
functions/sdk-integration.md›
# Functions SDK Integration
Use InsForge SDK to invoke serverless functions from your frontend application.
## Setup
First, ensure your `.env` file is configured with your InsForge URL and anon key. Get the anon key with `npx -y @insforge/cli secrets get ANON_KEY`. See the main [SKILL.md](../SKILL.md) for framework-specific variable names and full setup steps.
```javascript
import { createClient } from '@insforge/sdk'
const insforge = createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL, // adjust prefix for your framework
anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY // adjust prefix for your framework
})
```
## Invoke Function
```javascript
// POST with body (default)
const { data, error } = await insforge.functions.invoke('hello-world', {
body: { name: 'World' }
})
// GET request
const { data, error } = await insforge.functions.invoke('get-stats', {
method: 'GET'
})
// With custom headers
const { data, error } = await insforge.functions.invoke('api-endpoint', {
method: 'PUT',
body: { id: '123', status: 'active' },
headers: { 'X-Custom-Header': 'value' }
})
```
## Important Notes
- SDK automatically includes user's auth token
- All methods return `{ data, error }` - always check for errors
- Function must be deployed and have `status: "active"` to be invokable
---
## Best Practices
1. **Verify function exists and is active before invoking**
- Check available functions via CLI: `insforge functions list`
- If no functions exist, create one first via admin API
- Ensure function `status` is `"active"` (not `"draft"`)
2. **Handle errors gracefully**
- Functions may fail due to code errors or timeouts
- Always check `error` in response
## Common Mistakes
| Mistake | Solution |
|---------|----------|
| ❌ Invoking without checking function exists | ✅ Verify function via admin API first |
| ❌ Invoking a draft function | ✅ Ensure function status is `"active"` |
| ❌ Ignoring errors | ✅ Always handle `error` in response |
## Recommended Workflow
```
1. Check available functions → insforge functions list
2. If no function exists → Create one first with status: "active"
3. Invoke function → Use function slug
4. Handle response → Check for errors
```
payments/razorpay.md›
# Razorpay Payments SDK Integration
Use this guide when writing app code for Razorpay payments through `@insforge/sdk`. For CLI setup, catalog sync, manual webhook instructions, and fulfillment migrations, use the `insforge-cli` payments references first.
## Mental Model
- Razorpay one-time payments use Orders plus Razorpay Checkout.js.
- Razorpay subscriptions use Plans. A Plan is a recurring definition around an Item.
- Razorpay Checkout runs inside the app; InsForge returns `checkoutOptions`, not a hosted redirect URL.
- Client signature verification proves the Checkout callback is authentic. It is not the durable fulfillment signal.
- Durable fulfillment comes from verified rows in `payments.webhook_events`.
- `payments.transactions` is for dashboard/reporting. Do not use it as the primary business workflow.
Do not use Stripe Checkout, Stripe Prices, or Billing Portal concepts in a Razorpay flow.
## Setup Check
Before writing app code:
```bash
npx -y @insforge/cli payments razorpay status
npx -y @insforge/cli payments razorpay catalog --environment test
```
If Razorpay is unconfigured, ask the developer/admin to configure Key ID and Key Secret first. Default to `test`; use `live` only after explicit approval.
Razorpay webhooks are manual. Confirm the developer configured the webhook URL, webhook secret, and recommended events in the Razorpay Dashboard before relying on fulfillment.
## RLS Before Orders And Subscriptions
Razorpay runtime routes use the caller's InsForge token for authorization probes.
Use these managed tables:
- `payments.razorpay_orders`: `INSERT` for one-time order creation. Add `SELECT` only when the app needs to read order attempts.
- `payments.razorpay_subscriptions`: `INSERT` for subscription creation.
- `payments.razorpay_subscriptions`: `UPDATE` policy for cancel, pause, and resume authorization. The backend only probes `updated_at`; it does not give frontend users direct access to state columns.
Example for team billing:
```sql
CREATE OR REPLACE FUNCTION public.is_team_billing_admin(team_id text)
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1
FROM public.team_members
WHERE team_members.team_id::text = is_team_billing_admin.team_id
AND team_members.user_id = auth.uid()
AND team_members.role IN ('owner', 'admin')
);
$$;
ALTER TABLE payments.razorpay_orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE payments.razorpay_subscriptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "team admins create razorpay orders"
ON payments.razorpay_orders
FOR INSERT
TO authenticated
WITH CHECK (
subject_type = 'team'
AND public.is_team_billing_admin(subject_id)
);
CREATE POLICY "team admins create razorpay subscriptions"
ON payments.razorpay_subscriptions
FOR INSERT
TO authenticated
WITH CHECK (
subject_type = 'team'
AND public.is_team_billing_admin(subject_id)
);
CREATE POLICY "team admins manage razorpay subscriptions"
ON payments.razorpay_subscriptions
FOR UPDATE
TO authenticated
USING (
subject_type = 'team'
AND public.is_team_billing_admin(subject_id)
)
WITH CHECK (
subject_type = 'team'
AND public.is_team_billing_admin(subject_id)
);
```
For user-owned billing, replace the helper with `subject_type = 'user' AND subject_id = auth.uid()::text`. Do not let users submit arbitrary `subject` values without a matching policy.
## One-Time Order Flow
Load Razorpay Checkout.js in the app:
```html
<script src="https://checkout.razorpay.com/v1/checkout.js"></script>
```
Create an app-owned pending record first, then create a Razorpay Order:
```typescript
const { data: order, error: orderError } = await insforge.database
.from('orders')
.insert([{ team_id: teamId, status: 'pending' }])
.select()
.single()
if (orderError) throw orderError
const { data, error } = await insforge.payments.razorpay.createOrder('test', {
amount: 50000,
currency: 'INR',
subject: { type: 'team', id: teamId },
customerName: user.name ?? null,
customerEmail: user.email ?? null,
notes: { order_id: order.id }
})
if (error) throw error
const checkout = new Razorpay({
...data.checkoutOptions,
handler: async (response) => {
const verified = await insforge.payments.razorpay.verifyOrder('test', {
orderId: response.razorpay_order_id,
paymentId: response.razorpay_payment_id,
signature: response.razorpay_signature
})
if (verified.error) throw verified.error
window.location.assign(`/orders/${order.id}`)
}
})
checkout.open()
```
Do not put UUIDs in Razorpay `receipt`. Razorpay limits `receipt` to a unique internal reference of at most 40 characters.
Omit it unless the app already has a short unique receipt number such as `RCP-10042`; keep app UUIDs in Razorpay `notes`.
After verification, show pending or processing state until the app-owned order is updated by webhook fulfillment.
## Subscription Flow
Create or sync the Razorpay Plan first. Then create the subscription and open Checkout:
```typescript
const { data, error } = await insforge.payments.razorpay.createSubscription('test', {
planId: 'plan_123',
totalCount: 12,
subject: { type: 'team', id: teamId },
customerName: user.name ?? null,
customerEmail: user.email ?? null
})
if (error) throw error
const checkout = new Razorpay({
...data.checkoutOptions,
handler: async (response) => {
const verified = await insforge.payments.razorpay.verifySubscription('test', {
subscriptionId: response.razorpay_subscription_id,
paymentId: response.razorpay_payment_id,
signature: response.razorpay_signature
})
if (verified.error) throw verified.error
window.location.assign('/billing')
}
})
checkout.open()
```
## Subscription Management
Razorpay does not have a hosted Billing Portal equivalent. Use backend routes through the SDK:
```typescript
await insforge.payments.razorpay.cancelSubscription('test', 'sub_123', {
cancelAtCycleEnd: false
})
await insforge.payments.razorpay.pauseSubscription('test', 'sub_123')
await insforge.payments.razorpay.resumeSubscription('test', 'sub_123')
```
These routes evaluate `UPDATE` policies on `payments.razorpay_subscriptions`; they do not let the user directly mutate provider-managed subscription state.
## Webhooks And Fulfillment
Razorpay webhook setup is manual. From the InsForge dashboard (Dashboard -> Payments -> Settings -> Webhooks), copy:
- Webhook URL, for example `/api/webhooks/razorpay/test`
- Webhook secret
Then create a webhook in the Razorpay Dashboard with that URL and secret, and select the events InsForge handles.
Razorpay can only deliver webhooks to a public HTTPS URL.
Events InsForge handles:
- `payment.authorized`
- `payment.captured`
- `payment.failed`
- `subscription.created`
- `subscription.activated`
- `subscription.charged`
- `subscription.updated`
- `subscription.cancelled`
- `subscription.paused`
- `subscription.resumed`
- `subscription.halted`
- `subscription.completed`
- `subscription.expired`
- `refund.created`
- `refund.processed`
- `refund.failed`
- `invoice.paid`
- `invoice.expired`
- `order.paid`
Create app-owned fulfillment triggers on `payments.webhook_events`, not on Checkout callback verification and not on `payments.transactions`.
```sql
CREATE OR REPLACE FUNCTION public.fulfill_razorpay_order()
RETURNS TRIGGER AS $$
DECLARE
app_order_id text;
BEGIN
app_order_id := COALESCE(
NEW.payload -> 'payload' -> 'payment' -> 'entity' -> 'notes' ->> 'order_id',
NEW.payload -> 'payload' -> 'order' -> 'entity' -> 'notes' ->> 'order_id',
NEW.payload -> 'payload' -> 'invoice' -> 'entity' -> 'notes' ->> 'order_id'
);
IF NEW.provider = 'razorpay'
AND NEW.processing_status = 'processed'
AND NEW.event_type IN ('payment.captured', 'order.paid', 'invoice.paid')
AND app_order_id IS NOT NULL THEN
UPDATE public.orders
SET status = 'paid',
paid_at = COALESCE(NEW.processed_at, NOW())
WHERE id::text = app_order_id
AND status = 'pending';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER fulfill_razorpay_order_from_webhook
AFTER INSERT OR UPDATE ON payments.webhook_events
FOR EACH ROW
EXECUTE FUNCTION public.fulfill_razorpay_order();
```
For subscriptions, resolve the billing subject from the subscription entity's `notes` in the event payload — InsForge stamps `insforge_subject_type` and `insforge_subject_id` into notes at subscription creation (and creates the `payments.customer_mappings` row at the same time, so the mapping is also a safe fallback):
```sql
CREATE OR REPLACE FUNCTION public.grant_razorpay_subscription_access()
RETURNS TRIGGER AS $$
DECLARE
v_subject_type TEXT;
v_subject_id TEXT;
BEGIN
IF NEW.provider = 'razorpay'
AND NEW.event_type = 'subscription.charged'
AND NEW.processing_status = 'processed' THEN
v_subject_type := NEW.payload -> 'payload' -> 'subscription' -> 'entity'
-> 'notes' ->> 'insforge_subject_type';
v_subject_id := NEW.payload -> 'payload' -> 'subscription' -> 'entity'
-> 'notes' ->> 'insforge_subject_id';
IF v_subject_id IS NULL THEN
SELECT m.subject_type, m.subject_id
INTO v_subject_type, v_subject_id
FROM payments.customer_mappings m
WHERE m.provider = NEW.provider
AND m.environment = NEW.environment
AND m.provider_customer_id = NEW.payload -> 'payload' -> 'subscription'
-> 'entity' ->> 'customer_id';
END IF;
IF v_subject_id IS NULL THEN
RAISE WARNING 'Razorpay event % has no resolvable billing subject', NEW.provider_event_id;
RETURN NEW;
END IF;
-- Branch on the subject type sent at creation; team_id is a UUID here,
-- so the type check also guards the cast.
IF v_subject_type = 'team' THEN
INSERT INTO public.team_entitlements (team_id, plan, active, updated_at)
VALUES (v_subject_id::uuid, 'pro', true, NOW())
ON CONFLICT (team_id) DO UPDATE SET
plan = EXCLUDED.plan,
active = true,
updated_at = NOW();
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER grant_razorpay_subscription_access_from_webhook
AFTER INSERT OR UPDATE ON payments.webhook_events
FOR EACH ROW
EXECUTE FUNCTION public.grant_razorpay_subscription_access();
```
Handle revocation the same way from `subscription.cancelled`, `subscription.halted`, and `subscription.expired`.
Keep trigger functions idempotent. For external side effects such as email or warehouse work, write an app-owned outbox row and process it from an edge function or worker.
## Runtime State
Use app-owned tables for end-user state:
- `public.orders`
- `public.credit_ledger`
- `public.team_entitlements`
- `public.billing_status`
Use `payments.transactions` only for admin/debug dashboards and provider reference IDs. Do not expose `payments.customer_mappings`, `payments.transactions`, or provider-native subscription rows directly to end users.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Expecting a hosted Checkout URL | Load Razorpay Checkout.js and pass `checkoutOptions` |
| Treating `verifyOrder` as fulfillment | Use verified webhook rows in `payments.webhook_events` |
| Using Stripe Prices for Razorpay subscriptions | Use Razorpay Items and Plans |
| Forgetting manual webhook setup | Configure Razorpay Dashboard webhook URL, secret, and events |
| Letting users manage another team's subscription | Add `UPDATE` RLS on `payments.razorpay_subscriptions` |
| Reading `payments.transactions` as app state | Maintain app-owned order or entitlement tables |
payments/stripe.md›
# Stripe Payments SDK Integration
Use this guide when writing app code for Stripe payments through `@insforge/sdk`. For CLI setup, catalog sync, webhook setup, and fulfillment migrations, use the `insforge-cli` payments references first.
## Mental Model
- Stripe uses Products and Prices. A subscription is still created through Checkout using recurring Prices.
- InsForge SDK creates Stripe Checkout Sessions and Billing Portal Sessions.
- Stripe hosts Checkout and Billing Portal; the app redirects to returned URLs.
- Checkout success URLs are UX redirects only. Durable fulfillment comes from verified rows in `payments.webhook_events`.
- `payments.transactions` is for dashboard/reporting. Do not use it as the primary business workflow.
Do not use Razorpay Orders, Items, Plans, or Checkout.js concepts in a Stripe flow.
## Setup Check
Before writing app code:
```bash
npx -y @insforge/cli payments stripe status
npx -y @insforge/cli payments stripe catalog --environment test
```
If Stripe is unconfigured, ask the developer/admin to configure a Stripe key and sync catalog first. Default to `test`; use `live` only after explicit approval.
## RLS Before Checkout
Checkout and portal creation use the caller's InsForge token. If the app exposes billing subjects such as teams or organizations, add RLS before wiring UI.
Use these managed tables for Stripe runtime authorization:
- `payments.stripe_checkout_sessions`: `INSERT` for creating Checkout attempts, `SELECT` for reading/retrying attempts.
- `payments.stripe_customer_portal_sessions`: `INSERT` for portal attempts, `SELECT` for reading attempts.
If checkout sends `idempotencyKey`, retries need `SELECT` on the matching `payments.stripe_checkout_sessions` row because the backend may find an existing row after `ON CONFLICT`.
Example for user-owned checkout:
```sql
ALTER TABLE payments.stripe_checkout_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE payments.stripe_customer_portal_sessions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users create their stripe checkout sessions"
ON payments.stripe_checkout_sessions
FOR INSERT
TO authenticated
WITH CHECK (
subject_type = 'user'
AND subject_id = auth.uid()::text
);
CREATE POLICY "users read their stripe checkout sessions"
ON payments.stripe_checkout_sessions
FOR SELECT
TO authenticated
USING (
subject_type = 'user'
AND subject_id = auth.uid()::text
);
CREATE POLICY "users create their stripe portal sessions"
ON payments.stripe_customer_portal_sessions
FOR INSERT
TO authenticated
WITH CHECK (
subject_type = 'user'
AND subject_id = auth.uid()::text
);
CREATE POLICY "users read their stripe portal sessions"
ON payments.stripe_customer_portal_sessions
FOR SELECT
TO authenticated
USING (
subject_type = 'user'
AND subject_id = auth.uid()::text
);
```
For teams or organizations, replace `auth.uid()` with a `SECURITY DEFINER` membership helper. Do not let users submit arbitrary `subject` values without a matching policy.
## One-Time Checkout
Create the app-owned pending record first, then create Checkout:
```typescript
const { data: order, error: orderError } = await insforge.database
.from('orders')
.insert([{ user_id: user.id, status: 'pending' }])
.select()
.single()
if (orderError) throw orderError
const { data, error } = await insforge.payments.stripe.createCheckoutSession('test', {
mode: 'payment',
lineItems: [{ priceId: 'price_123', quantity: 1 }],
successUrl: `${window.location.origin}/orders/${order.id}`,
cancelUrl: `${window.location.origin}/pricing`,
subject: { type: 'user', id: user.id },
customerEmail: user.email ?? null,
metadata: { order_id: order.id },
idempotencyKey: `order:${order.id}`
})
if (error) throw error
if (data?.checkoutSession.url) {
window.location.assign(data.checkoutSession.url)
}
```
For anonymous one-time purchases, `subject` can be omitted, but use a narrow app-owned correlation record and `customerEmail` when available.
## Subscription Checkout
Subscription checkout requires `subject` because ongoing entitlement belongs to an app-defined billing owner.
```typescript
const { data, error } = await insforge.payments.stripe.createCheckoutSession('test', {
mode: 'subscription',
lineItems: [{ priceId: 'price_monthly_123', quantity: 1 }],
successUrl: `${window.location.origin}/billing/success`,
cancelUrl: `${window.location.origin}/billing`,
subject: { type: 'team', id: teamId },
customerEmail: user.email,
idempotencyKey: `team:${teamId}:pro-monthly`
})
if (error) throw error
if (data?.checkoutSession.url) {
window.location.assign(data.checkoutSession.url)
}
```
## Customer Portal
Use Stripe Billing Portal for existing customers who need to manage payment methods, invoices, cancellations, or subscriptions.
```typescript
const { data, error } =
await insforge.payments.stripe.createCustomerPortalSession('test', {
subject: { type: 'team', id: teamId },
returnUrl: `${window.location.origin}/billing`
})
if (error) throw error
if (data?.customerPortalSession.url) {
window.location.assign(data.customerPortalSession.url)
}
```
Portal creation requires an authenticated user and an existing `payments.customer_mappings` row for the billing subject. If the backend returns `404`, show a subscribe or checkout CTA instead.
## Webhooks And Fulfillment
Stripe webhooks are managed by InsForge when the backend has a public URL:
```bash
npx -y @insforge/cli payments stripe webhooks configure --environment test
```
InsForge's Stripe managed event set:
- `customer.created`
- `customer.updated`
- `customer.deleted`
- `checkout.session.completed`
- `checkout.session.async_payment_succeeded`
- `checkout.session.async_payment_failed`
- `checkout.session.expired`
- `invoice.paid`
- `invoice.payment_failed`
- `payment_intent.succeeded`
- `payment_intent.payment_failed`
- `charge.refunded`
- `refund.created`
- `refund.updated`
- `refund.failed`
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `customer.subscription.paused`
- `customer.subscription.resumed`
Create app-owned fulfillment triggers on `payments.webhook_events`, not on success URLs and not on `payments.transactions`.
Webhook events are verified and processed independently. InsForge commits all rows derived from an event before marking that event `processed`, but Stripe gives no ordering guarantee across events: `invoice.paid` can be processed before `checkout.session.completed`, so rows created by another event (such as `payments.customer_mappings`) may not exist yet when a trigger fires. Resolve the billing subject from the event payload first and use `payments.customer_mappings` only as a fallback. Never let fulfillment skip silently: log or dead-letter events that cannot be resolved.
### One-Time Fulfillment
```sql
CREATE OR REPLACE FUNCTION public.fulfill_stripe_paid_order()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.provider = 'stripe'
AND NEW.processing_status = 'processed'
AND NEW.event_type IN (
'checkout.session.completed',
'checkout.session.async_payment_succeeded',
'payment_intent.succeeded',
'invoice.paid'
)
AND (NEW.payload -> 'data' -> 'object' -> 'metadata' ->> 'order_id') IS NOT NULL THEN
UPDATE public.orders
SET status = 'paid',
paid_at = COALESCE(NEW.processed_at, NOW())
WHERE id::text = NEW.payload -> 'data' -> 'object' -> 'metadata' ->> 'order_id'
AND status = 'pending';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER fulfill_stripe_paid_order_from_webhook
AFTER INSERT OR UPDATE ON payments.webhook_events
FOR EACH ROW
EXECUTE FUNCTION public.fulfill_stripe_paid_order();
```
### Subscription Fulfillment
Subscription events do not carry the app `metadata` sent at checkout. Resolve the billing subject from the subscription metadata embedded in the event payload — InsForge stamps `insforge_subject_type` and `insforge_subject_id` at checkout, and Stripe snapshots it onto subscription-generated invoices as `parent.subscription_details.metadata`. Check `invoice.metadata` next, then fall back to `payments.customer_mappings` (the same order InsForge uses internally):
```sql
CREATE OR REPLACE FUNCTION public.grant_subscription_access()
RETURNS TRIGGER AS $$
DECLARE
v_subject_type TEXT;
v_subject_id TEXT;
BEGIN
IF NEW.provider = 'stripe'
AND NEW.event_type = 'invoice.paid'
AND NEW.processing_status = 'processed' THEN
v_subject_type := COALESCE(
NEW.payload -> 'data' -> 'object' -> 'parent'
-> 'subscription_details' -> 'metadata' ->> 'insforge_subject_type',
NEW.payload -> 'data' -> 'object' -> 'metadata' ->> 'insforge_subject_type'
);
v_subject_id := COALESCE(
NEW.payload -> 'data' -> 'object' -> 'parent'
-> 'subscription_details' -> 'metadata' ->> 'insforge_subject_id',
NEW.payload -> 'data' -> 'object' -> 'metadata' ->> 'insforge_subject_id'
);
IF v_subject_id IS NULL THEN
SELECT m.subject_type, m.subject_id
INTO v_subject_type, v_subject_id
FROM payments.customer_mappings m
WHERE m.provider = NEW.provider
AND m.environment = NEW.environment
AND m.provider_customer_id = NEW.payload -> 'data' -> 'object' ->> 'customer';
END IF;
IF v_subject_id IS NULL THEN
RAISE WARNING 'Stripe event % has no resolvable billing subject', NEW.provider_event_id;
RETURN NEW;
END IF;
-- Branch on the subject type sent at checkout; team_id is a UUID here,
-- so the type check also guards the cast.
IF v_subject_type = 'team' THEN
INSERT INTO public.team_entitlements (team_id, plan, active, updated_at)
VALUES (v_subject_id::uuid, 'pro', true, NOW())
ON CONFLICT (team_id) DO UPDATE SET
plan = EXCLUDED.plan,
active = true,
updated_at = NOW();
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER grant_subscription_access_from_stripe_webhook
AFTER INSERT OR UPDATE ON payments.webhook_events
FOR EACH ROW
EXECUTE FUNCTION public.grant_subscription_access();
```
Adjust the entitlement table to the billing subject type used at checkout. Handle revocation the same way from `customer.subscription.deleted` and `customer.subscription.updated` (`payload -> 'data' -> 'object' -> 'metadata'` holds the subject keys on subscription events).
Keep trigger functions idempotent. For external side effects such as email or warehouse work, write an app-owned outbox row and process it from an edge function or worker.
## Runtime State
Use app-owned tables for end-user state:
- `public.orders`
- `public.credit_ledger`
- `public.team_entitlements`
- `public.billing_status`
Use `payments.transactions` only for admin/debug dashboards and provider reference IDs. Do not expose `payments.customer_mappings`, `payments.transactions`, `payments.stripe_subscriptions`, or `payments.stripe_subscription_items` directly to end users.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Calling payment methods at the root `payments` namespace | Use `insforge.payments.stripe.*` |
| Sending provider-prefixed price fields | Send `priceId` |
| Marking paid from success URL | Fulfill from `payments.webhook_events` |
| Starting shared-subject checkout before RLS | Add policies on `payments.stripe_checkout_sessions` first |
| Idempotent retry fails after adding only `INSERT` | Add matching `SELECT` for retryable checkout rows |
| Trying to update a Stripe Price amount | Create a new Price and archive the old one |
| Resolving subscription subjects only via `payments.customer_mappings` | Read `insforge_subject_*` from the event payload first; events from other types may not have created the mapping yet |
realtime/sdk-integration.md›
# Real-time SDK Integration
Use InsForge SDK for WebSocket pub/sub messaging in your frontend application.
## Setup
First, ensure your `.env` file is configured with your InsForge URL and anon key. Get the anon key with `npx -y @insforge/cli secrets get ANON_KEY`. See the main [SKILL.md](../SKILL.md) for framework-specific variable names and full setup steps.
```javascript
import { createClient } from '@insforge/sdk'
const insforge = createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL, // adjust prefix for your framework
anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY // adjust prefix for your framework
})
```
For Next.js / SSR Client Components, use `@insforge/sdk/ssr` so the browser WebSocket can authenticate with the access-token cookie:
```typescript
import { createBrowserClient } from '@insforge/sdk/ssr'
const insforge = createBrowserClient()
```
Use `createBrowserClient()` for authenticated browser Realtime connections. It reads `insforge_access_token`, refreshes through `/api/auth/refresh`, and applies same-user refreshes with `AuthChangeEvent.TOKEN_REFRESHED` internally so active Realtime sockets stay connected; the fresh JWT is used on the next handshake.
## Backend Setup
If the task needs channel patterns, database triggers, or channel/message RLS, use the **insforge-cli** skill's [realtime](../../insforge-cli/references/realtime.md) reference. This SDK guide covers frontend connection, subscription, publishing, and presence handling.
## Usage Examples
### Connect
```javascript
await insforge.realtime.connect()
console.log('Connected:', insforge.realtime.isConnected)
```
### Subscribe to Channel
```javascript
const response = await insforge.realtime.subscribe('order:123')
if (!response.ok) {
console.error('Failed:', response.error?.message)
} else {
console.log('Subscribed to:', response.channel)
console.log('Members already present:', response.presence.members)
}
// Auto-connects if not connected
```
### Presence Snapshot on Subscribe
A successful `subscribe()` always returns a `presence` snapshot:
```javascript
const subscribeResponse = {
ok: true,
channel: 'order:123',
presence: {
members: [
{
type: 'user',
presenceId: 'user-123',
joinedAt: '2026-04-25T17:00:00.000Z'
}
]
}
}
```
Use this snapshot to seed local participant state before listening for live deltas.
- `presence.members` is the initial source of truth for who is already in the channel
- `presenceId` is the stable key for a member: user ID for `type: 'user'`, socket ID for `type: 'anonymous'`
- Authenticated users are deduplicated into one logical member across multiple sockets or tabs
- Anonymous connections are tracked per socket, so multiple tabs show up as separate members
- Initialize the current user's presence from the `subscribe()` response; your own presence is already represented there
### Listen for Events
```javascript
// Listen for events
insforge.realtime.on('status_changed', (payload) => {
console.log('Status:', payload.status)
console.log('Meta:', payload.meta.messageId, payload.meta.timestamp)
})
// Presence deltas for other members in the channel
insforge.realtime.on('presence:join', (message) => {
console.log('Member joined:', message.member.presenceId, message.member.type)
})
insforge.realtime.on('presence:leave', (message) => {
console.log('Member left:', message.member.presenceId)
})
// Listen once
insforge.realtime.once('order_completed', (payload) => {
console.log('Completed:', payload)
})
// Remove listener
insforge.realtime.off('status_changed', handler)
```
### Integrate Presence into UI State
```javascript
const channel = `chat:${roomId}`
const response = await insforge.realtime.subscribe(channel)
if (!response.ok) throw new Error(response.error?.message || 'Subscribe failed')
let members = response.presence.members
renderMembers(members)
const handleJoin = ({ member, meta }) => {
if (meta.channel !== channel) return
const exists = members.some((current) => current.presenceId === member.presenceId)
members = exists ? members : [...members, member]
renderMembers(members)
}
const handleLeave = ({ member, meta }) => {
if (meta.channel !== channel) return
members = members.filter((current) => current.presenceId !== member.presenceId)
renderMembers(members)
}
insforge.realtime.on('presence:join', handleJoin)
insforge.realtime.on('presence:leave', handleLeave)
```
### Publish Messages
```javascript
// Must be subscribed to channel first
await insforge.realtime.publish('chat:room-1', 'new_message', {
text: 'Hello!',
sender: 'Alice'
})
```
### Unsubscribe and Disconnect
```javascript
insforge.realtime.unsubscribe('order:123')
insforge.realtime.disconnect()
```
### Connection Events
```javascript
insforge.realtime.on('connect', () => console.log('Connected'))
insforge.realtime.on('disconnect', (reason) => console.log('Disconnected:', reason))
insforge.realtime.on('connect_error', (err) => console.error('Error:', err))
insforge.realtime.on('error', ({ code, message }) => console.error(code, message))
```
Error codes: `UNAUTHORIZED`, `NOT_SUBSCRIBED`, `INTERNAL_ERROR`
### Properties
```javascript
insforge.realtime.isConnected // boolean
insforge.realtime.connectionState // 'disconnected' | 'connecting' | 'connected'
insforge.realtime.socketId // string
insforge.realtime.getSubscribedChannels() // string[]
```
### Message Metadata
All messages include `meta`:
```javascript
const message = {
meta: {
messageId: 'uuid',
channel: 'order:123',
senderType: 'system' | 'user',
senderId: 'user-uuid', // if user
timestamp: 'ISO string'
},
// ...payload fields
}
```
### Complete Example
```javascript
await insforge.realtime.connect()
const channel = `order:${orderId}`
const response = await insforge.realtime.subscribe(channel)
if (!response.ok) throw new Error(response.error?.message || 'Subscribe failed')
let members = response.presence.members
renderPresence(members)
insforge.realtime.on('status_changed', (payload) => {
updateUI(payload.status)
})
insforge.realtime.on('presence:join', ({ member, meta }) => {
if (meta.channel !== channel) return
const exists = members.some((current) => current.presenceId === member.presenceId)
members = exists ? members : [...members, member]
renderPresence(members)
})
insforge.realtime.on('presence:leave', ({ member, meta }) => {
if (meta.channel !== channel) return
members = members.filter((current) => current.presenceId !== member.presenceId)
renderPresence(members)
})
// Client can also publish
await insforge.realtime.publish(channel, 'viewed', {
viewedAt: new Date().toISOString()
})
```
---
## Best Practices
1. **Ensure channel pattern exists before subscribing**
- The frontend can only subscribe to channel names that match an enabled backend channel pattern.
- If no channel pattern exists, finish backend setup with the **insforge-cli** skill first.
2. **Seed presence from `subscribe()` before processing deltas**
- Treat `response.presence.members` as the initial source of truth
- Apply `presence:join` and `presence:leave` as incremental updates after the subscribe call succeeds
- Use `presenceId` as your stable UI key
3. **Handle connection events and rebuild presence after reconnect**
- Listen for `connect`, `disconnect`, and `connect_error` events
- Presence is ephemeral and tracked in-memory on a single backend instance, so reconnect by subscribing again and rebuilding from the returned snapshot
4. **Gate user-dependent side effects on auth hydration**
- Webhook-backed events can arrive before a cold-load `getCurrentUser()` refresh finishes
- If an event branches on the current user, wait for `authLoading === false` before running it or flipping a "first event wins" guard
- See [../auth/sdk-integration.md#gate-user-dependent-side-effects-during-auth-loading](../auth/sdk-integration.md#gate-user-dependent-side-effects-during-auth-loading)
5. **Design for presence visibility rules**
- Authenticated subscribers expose their user ID through `presenceId` to other channel members
- Use non-presence channels when subscriber identity should stay opaque
6. **Clean up subscriptions**
- Unsubscribe from channels when no longer needed
- Disconnect when leaving the page/component
### Recommended Workflow
```text
1. Confirm backend setup → channel pattern exists and is enabled
2. Connect to realtime → await insforge.realtime.connect()
3. Subscribe and seed presence → const response = await insforge.realtime.subscribe('channel')
4. Listen for events and deltas → on('event', handler) + on('presence:join'/'presence:leave')
5. Clean up on unmount → unsubscribe() and disconnect()
```
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Subscribing before backend channel setup exists | Configure channel patterns, database triggers, and RLS before subscribing |
| Waiting for a self `presence:join` event | Initialize local presence state from `subscribe()` response |
| Treating presence as durable/global state | Treat presence as single-instance, in-memory state and resubscribe after reconnects |
| Missing connection error handling | Listen for `connect_error` and `disconnect` events |
| Leaving subscriptions active after unmount | Unsubscribe on component unmount |
| Publishing before subscribing | Subscribe to the channel before publishing |
SKILL.md›
---
name: insforge
description: >-
Use this skill when writing app code with InsForge or @insforge/sdk: database CRUD, auth, storage uploads/storage RLS, functions, OpenRouter AI, realtime, emails, Stripe or Razorpay payments, or pointing S3-compatible tooling (aws CLI, AWS SDKs, rclone, Terraform, boto3) at InsForge Storage. Trigger on requests like add auth, fetch data, upload files, make a bucket public, add checkout, sell subscriptions, or send email. For infrastructure, SQL migrations, CLI commands, or payment provider setup, use insforge-cli instead.
license: Apache-2.0
---
# InsForge App Integration Skill
This skill covers **client-side SDK integration** using `@insforge/sdk`. For backend infrastructure operations (creating tables, inspecting schema, deploying functions, secrets, managing storage buckets, configuring payment provider keys/catalog, website deployments, cron job and schedules, logs, etc.), use the **insforge-cli** skill.
## Quick Setup
### 1. Install the SDK
```bash
npm install @insforge/sdk@latest
```
### 2. Set up environment variables
Before using the SDK, create a `.env` file (or `.env.local` for Next.js) in your project root with your InsForge URL and anon key.
#### How to get your URL and anon key
1. **Ensure the project is linked.** Check for `.insforge/project.json` in the project root.
- Generate it with `npx -y @insforge/cli link` for an existing project or `npx -y @insforge/cli create` for a new project.
2. **Get the anon key** via the CLI:
```bash
npx -y @insforge/cli secrets get ANON_KEY
```
3. **Get the URL** from the `oss_host` field in `.insforge/project.json` (e.g., `https://myapp.us-east.insforge.app`).
4. **Write both values** to the `.env` file using the correct framework prefix (see table below).
> **Important:** Use the anon key for user-scoped SDK clients, including SSR. For privileged server-only app code that needs admin/service access, use `createAdminClient({ apiKey })`; the API key is a full-access admin key, equivalent to a service role key on other platforms.
Use the correct environment variable prefix and access pattern for your framework:
| Framework | `.env` file | Variables | Access Pattern |
| ----------------------------- | ------------ | ----------------------------------------------------------- | ------------------------------------------- |
| **Next.js** | `.env.local` | `NEXT_PUBLIC_INSFORGE_URL`, `NEXT_PUBLIC_INSFORGE_ANON_KEY` | `process.env.NEXT_PUBLIC_*` |
| **Vite** (React, Vue, Svelte) | `.env` | `VITE_INSFORGE_URL`, `VITE_INSFORGE_ANON_KEY` | `import.meta.env.VITE_*` |
| **Astro** | `.env` | `PUBLIC_INSFORGE_URL`, `PUBLIC_INSFORGE_ANON_KEY` | `import.meta.env.PUBLIC_*` |
| **SvelteKit** | `.env` | `PUBLIC_INSFORGE_URL`, `PUBLIC_INSFORGE_ANON_KEY` | `import { env } from '$env/dynamic/public'` |
| **Create React App** | `.env` | `REACT_APP_INSFORGE_URL`, `REACT_APP_INSFORGE_ANON_KEY` | `process.env.REACT_APP_*` |
| **Node.js / Server** | `.env` | `INSFORGE_URL`, `INSFORGE_ANON_KEY` | `process.env.*` |
Example `.env.local` for Next.js:
```bash
NEXT_PUBLIC_INSFORGE_URL=https://your-appkey.us-east.insforge.app
NEXT_PUBLIC_INSFORGE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...
```
> **Important:** Keep `.env` files local. Add `.env`, `.env.local`, and `.env*.local` to your `.gitignore` and keep `.env.example` for documenting required variables.
### 3. Initialize the client
Next.js:
```javascript
import { createClient } from '@insforge/sdk'
const insforge = createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL,
anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY
})
```
Vite:
```javascript
import { createClient } from '@insforge/sdk'
const insforge = createClient({
baseUrl: import.meta.env.VITE_INSFORGE_URL,
anonKey: import.meta.env.VITE_INSFORGE_ANON_KEY
})
```
Astro:
```javascript
import { createClient } from '@insforge/sdk'
const insforge = createClient({
baseUrl: import.meta.env.PUBLIC_INSFORGE_URL,
anonKey: import.meta.env.PUBLIC_INSFORGE_ANON_KEY
})
```
For trusted server-only code that needs project-admin access:
```javascript
import { createAdminClient } from "@insforge/sdk";
const admin = createAdminClient({
baseUrl: process.env.INSFORGE_URL,
apiKey: process.env.INSFORGE_API_KEY,
});
```
## Module Reference
| Module | Integration Guide |
| ------------- | ------------------------------------------------------------ |
| **Database** | [database/sdk-integration.md](database/sdk-integration.md) |
| **Auth** | [auth/sdk-integration.md](auth/sdk-integration.md) |
| **Storage** | [storage/sdk-integration.md](storage/sdk-integration.md) |
| **Functions** | [functions/sdk-integration.md](functions/sdk-integration.md) |
| **AI** | [ai/overview.md](ai/overview.md) |
| **Real-time** | [realtime/sdk-integration.md](realtime/sdk-integration.md) |
| **Email** | [email/sdk-integration.md](email/sdk-integration.md) |
| **Payments: Stripe** | [payments/stripe.md](payments/stripe.md) |
| **Payments: Razorpay** | [payments/razorpay.md](payments/razorpay.md) |
### What Each Module Covers
| Module | Content |
| ------------- | ------------------------------------------------------------------------------------------------------------- |
| **Database** | CRUD operations, filters, pagination, RPC calls |
| **Auth** | Sign up/in, OAuth, sessions, profiles, password reset |
| **Storage** | Upload, download, delete files; S3-compatible gateway for CI / backup tooling; write RLS policies for buckets |
| **Functions** | Invoke edge functions |
| **AI** | OpenRouter AI calls for chat, images, video, audio, embeddings, and model discovery |
| **Email** | Send custom transactional HTML emails (welcome, newsletter, notifications) |
| **Payments: Stripe** | Stripe Checkout Sessions, subscriptions, and Billing Portal redirects |
| **Payments: Razorpay** | Razorpay Orders, Subscriptions, Checkout.js, and subscription management |
| **Real-time** | Connect, subscribe, publish events, and track presence snapshots plus join/leave deltas |
### Guides
| Guide | When to Use |
| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [../insforge-cli/references/database/access-control.md](../insforge-cli/references/database/access-control.md) | Backend setup for application-table access control — covers RLS, infinite recursion prevention, `SECURITY DEFINER` patterns, performance tips, and common InsForge patterns |
| [storage/s3-gateway.md](storage/s3-gateway.md) | Fallback path when the consumer is existing S3 tooling (aws CLI, AWS SDKs, rclone, Terraform, boto3) and adopting `@insforge/sdk` is impractical — covers endpoint/region setup, access-key management, path-style addressing, and supported vs. not-supported S3 operations. **Requires InsForge 2.0.9+.** **Prefer the SDK** ([storage/sdk-integration.md](storage/sdk-integration.md)) for app code |
| [storage/postgres-rls.md](storage/postgres-rls.md) | Writing RLS policies for `storage.objects` — owner-only, public-read, path-scoped, team-shared, and the `NULL uploaded_by` caveat for mixed REST + S3 buckets |
| [../insforge-cli/references/database/vector.md](../insforge-cli/references/database/vector.md) | Backend setup for semantic search, recommendations, or RAG — covers the `vector` extension, schema/dimensions, distance operators, HNSW/IVFFlat indexes, and RPC similarity search |
| [ai/chat-completions.md](ai/chat-completions.md) | Text generation, structured answers, and streaming chat through OpenRouter |
| [ai/image-generation.md](ai/image-generation.md) | Image generation/editing through OpenRouter, then durable storage in InsForge Storage |
| [ai/video-generation.md](ai/video-generation.md) | Async OpenRouter video jobs, status polling, and storing generated media |
| [ai/audio.md](ai/audio.md) | Speech-to-text, text-to-speech, and storing audio assets/transcripts with InsForge |
| [ai/embeddings-and-rag.md](ai/embeddings-and-rag.md) | Generating embeddings through OpenRouter, storing them in pgvector, and wiring up a basic RAG pipeline |
| [ai/models-list.md](ai/models-list.md) | Discovering OpenRouter model IDs, modalities, parameters, pricing, and embedding dimensions |
| [payments](../insforge-cli/references/payments/overview.md) | Configuring Stripe/Razorpay keys, syncing provider catalog, setting up webhooks, and writing payment RLS before app integration |
### Building Payments for a New App
First choose the provider. There is no generic app payments guide:
- For Stripe Checkout, subscriptions, and Billing Portal, load [payments/stripe.md](payments/stripe.md).
- For Razorpay Orders, Subscriptions, Checkout.js, and cancel/pause/resume flows, load [payments/razorpay.md](payments/razorpay.md).
Before writing app code, check provider setup with the **insforge-cli** payments references:
```bash
npx -y @insforge/cli payments stripe status
npx -y @insforge/cli payments razorpay status
```
If the chosen provider is unconfigured, ask the developer/admin to configure that provider first.
### Real-time Backend Setup
The real-time SDK is for frontend event handling and messaging. Configure channel patterns, database triggers, and channel/message RLS with the **insforge-cli** skill; see [realtime](../insforge-cli/references/realtime.md).
### Backend Configuration
Supported project config knobs are managed via the CLI — use
`npx -y @insforge/cli config export/plan/apply` for auth redirect URLs,
verification flags, password policy, auth SMTP settings, storage upload size,
realtime/schedule retention, and cloud deployment subdomain. OAuth providers,
external app setup, storage buckets, functions, secrets, and deployment env vars
still use their dedicated dashboard or CLI flows. See the **insforge-cli**
skill's Configuration section.
### Risky backend changes? Use a branch first
When a code change in this skill depends on a **schema migration**, **new RLS policy**, **OAuth provider config change**, or any other backend change that affects prod behavior, create a backend branch first. Branches share `JWT_SECRET` (existing user JWTs keep working) but get a fresh database + EC2 + `API_KEY` / `ANON_KEY`, so you can test the SDK + backend change end-to-end in isolation.
The full branching workflow lives in the **insforge-cli** skill — see [branch](../insforge-cli/references/branch/overview.md) for the decision guide and lifecycle commands. Typical loop:
```bash
npx -y @insforge/cli branch create feat-x --mode schema-only
# ... apply migrations / change auth config / update RLS on the branch ...
# ... test the SDK against the branch backend ...
npx -y @insforge/cli branch merge feat-x --dry-run # review SQL
npx -y @insforge/cli branch merge feat-x # apply to parent
```
> ⚠ **After `branch create` or `branch switch`**, update the app's InsForge URL and anon-key env values, then **restart your dev server** (or re-source `.env`) so the SDK talks to the selected branch backend.
## SDK Quick Reference
All SDK methods return `{ data, error }`.
| Module | Methods |
| -------------------- | ---------------------------------------------------------------------------------------------------- |
| `insforge.database` | `.from().select()`, `.insert()`, `.update()`, `.delete()`, `.rpc()` |
| `insforge.auth` | `.signUp()`, `.signInWithPassword()`, `.signInWithOtp()` / `.verifyOtp()`, `.signInWithOAuth()`, `.signOut()`, `.getCurrentUser()` |
| `insforge.storage` | `.from().upload()`, `.uploadAuto()`, `.download()`, `.remove()` |
| `insforge.functions` | `.invoke()` |
| `insforge.ai` | Deprecated fallback only: `.chat.completions.create()`, `.images.generate()`, `.embeddings.create()` |
| `insforge.realtime` | `.connect()`, `.subscribe()`, `.publish()`, `.on()`, `.disconnect()` |
| `insforge.emails` | `.send({ to, subject, html, cc?, bcc?, from?, replyTo? })` |
| `insforge.payments.stripe` | `.createCheckoutSession()`, `.createCustomerPortalSession()` |
| `insforge.payments.razorpay` | `.createOrder()`, `.verifyOrder()`, `.createSubscription()`, `.verifySubscription()`, `.cancelSubscription()`, `.pauseSubscription()`, `.resumeSubscription()` |
## Important Notes
- **Database inserts require array format**: `insert([{...}])`
- **Bandwidth-efficient reads**: Name columns and `.limit()` list reads; never poll unbounded `select()` on an interval — subscribe with realtime (paired with a `realtime.publish` trigger) or poll a cheap ordered probe instead. Wasteful query shapes are the top cause of projects exhausting their monthly egress allowance. Probe snippet and full rules: [database/sdk-integration.md](database/sdk-integration.md#bandwidth-efficient-reads).
- **Next.js / SSR auth**: Use `@insforge/sdk/ssr` helpers (`createBrowserClient`, `createServerClient`, `createAuthActions`, `createRefreshAuthRouter`) and import `updateSession` from `@insforge/sdk/ssr/middleware` in Proxy/Middleware. Keep the refresh token httpOnly, run auth mutations through `createAuthActions()` on the server, return only safe app data from Server Actions, and let the browser read the short-lived access token for Storage/Realtime. See [auth/ssr-integration.md](auth/ssr-integration.md)
- **Storage**: Save both `url` AND `key` to database for download/delete operations
- **Functions invoke URL**: Prefer `insforge.functions.invoke(slug)` — the SDK owns route construction. For raw HTTP: the project base URL serves the compat path `/functions/{slug}`, while the functions deployment host (e.g. `*.function2.insforge.app`) serves the slug at root `/{slug}`
- **Email delivery**: Auth emails (signup verification, password reset, magic links, invites) ship on **every plan**. Custom email via `insforge.emails.send()` ships on **every paid plan**. Use the platform-managed delivery path; custom sender domain is dashboard config. See [email/sdk-integration.md](email/sdk-integration.md).
- **Payments**: Configure provider keys/catalog with `npx -y @insforge/cli payments <provider> ...` first; frontend code uses provider-scoped SDK modules.
- **Payment RLS**: Before payment UI, add app-specific RLS on provider runtime tables. Stripe uses `payments.stripe_checkout_sessions` and `payments.stripe_customer_portal_sessions`; Razorpay uses `payments.razorpay_orders` and `payments.razorpay_subscriptions`. Durable fulfillment triggers go on `payments.webhook_events`, not success URLs, Checkout callbacks, or `payments.transactions`.
- **Use Tailwind CSS v3.4**
- **Always local build before deploy**: Prevents wasted build resources and faster debugging
- **SDK package**: Use `@insforge/sdk` directly for all features including authentication.
- **Deployment**: Include a `vercel.json` in the project root for SPA routing (React, React Router apps). The `download-template` tool includes this automatically.
- **Branching for risky backend changes**: If your SDK code depends on a new schema, RLS policy, or auth config change, create a branch via `npx -y @insforge/cli branch create` first — see the **insforge-cli** skill's [branch](../insforge-cli/references/branch/overview.md) reference. After `branch create` / `branch switch`, update the app's InsForge URL and anon-key env values, then **restart the dev server**.
- **Hit an InsForge-side hurdle?** When the SDK, backend, docs, or a skill misbehaves — an API that should work but errors (`--type bug`), a capability you need that isn't supported (`--type feature-request`), docs that contradict actual behavior (`--type bug --component docs` with `--doc`/`--expected`) — report it and continue with a workaround: `npx -y @insforge/cli feedback --json --type <bug|feature-request|friction> --component <backend|sdk|cli|skills|docs> --title "..." --detail "..."`, adding `--language <lang>` for SDK issues (no login required; PII is redacted locally). See the **insforge-cli** skill's Feedback section for the full flag set and situation→type mapping. Never file feedback for problems in the app code you are writing.
storage/postgres-rls.md›
# Storage Row Level Security (RLS) for InsForge
## Overview
InsForge governs `storage.objects` with PostgreSQL Row Level Security, not with app-side `WHERE uploaded_by = $1` filters. A signed-in caller runs as `authenticated` with their JWT `sub` available via `auth.jwt() ->> 'sub'`. Admin/API-key callers bypass RLS for admin workflows.
**Core principle:** Policies are the contract. The user API does the cheapest possible thing on top of them — it does not re-implement authorization.
---
## InsForge Storage RLS Basics
| Role | Description | When active |
|------|-------------|-------------|
| `anon` | Unauthenticated callers | No valid session token |
| `authenticated` | Logged-in end users | Valid session token in the request |
The `auth.jwt()` helper returns the caller's full claims as `jsonb`. Most policies use `auth.jwt() ->> 'sub'` for ownership checks, but you can read any claim — `->> 'role'`, `->> 'org_id'`, custom claims from third-party providers (Better Auth, Clerk, Auth0, WorkOS, Stytch, Kinde).
### What ships by default
- **Fresh installs**: zero policies on `storage.objects` and RLS **disabled** (since migration 047). Policies you create stay dormant until RLS is enabled — always include `ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;` in your policy migration, or the table grants let any authenticated user read every row.
- **Existing projects** (any rows in `storage.buckets` at migration time): the owner-only set below is auto-installed so the upgrade does not silently break end-user uploads and reads.
### Managed Storage RLS
Allowed managed storage policy tables:
- `storage.objects`
Put `storage.objects` policy changes in a custom migration. This managed storage table can be managed with RLS.
### Path helpers shipped with InsForge
```sql
storage.foldername(name) -- text[] of folders, e.g. {alice, photos}
storage.filename(name) -- last segment, e.g. 'cat.jpg'
storage.extension(name) -- 'jpg' from 'alice/photos/cat.jpg'
```
### Inspecting current policies
```sql
SELECT polname, polcmd,
pg_get_expr(polqual, polrelid) AS using_clause,
pg_get_expr(polwithcheck, polrelid) AS check_clause
FROM pg_policy
WHERE polrelid = 'storage.objects'::regclass;
```
Check whether RLS is actually enabled — policies are dormant while this is `false`:
```sql
SELECT relrowsecurity FROM pg_class WHERE oid = 'storage.objects'::regclass;
```
### Removing the auto-installed defaults
When you want a different shape on a bucket the defaults don't fit:
```sql
DROP POLICY IF EXISTS storage_objects_owner_select ON storage.objects;
DROP POLICY IF EXISTS storage_objects_owner_insert ON storage.objects;
DROP POLICY IF EXISTS storage_objects_owner_update ON storage.objects;
DROP POLICY IF EXISTS storage_objects_owner_delete ON storage.objects;
```
---
## Pattern: Owner-only Bucket
**Use when:** Each user only sees, modifies, or deletes their own files. This is the default that ships auto-installed for existing projects.
| Caller | SELECT | INSERT | UPDATE | DELETE |
|--------|--------|--------|--------|--------|
| Owner of the row | ✓ | ✓ (must set `uploaded_by = sub`) | ✓ | ✓ |
| Other authenticated user | ✗ (404) | ✓ for own rows | ✗ | ✗ |
| Anonymous (`anon`) | ✗ | ✗ | ✗ | ✗ |
| Admin | ✓ bypass | ✓ bypass | ✓ bypass | ✓ bypass |
```sql
ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
CREATE POLICY storage_objects_owner_select ON storage.objects
FOR SELECT TO authenticated
USING (uploaded_by = (SELECT auth.jwt() ->> 'sub'));
CREATE POLICY storage_objects_owner_insert ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (uploaded_by = (SELECT auth.jwt() ->> 'sub'));
CREATE POLICY storage_objects_owner_update ON storage.objects
FOR UPDATE TO authenticated
USING (uploaded_by = (SELECT auth.jwt() ->> 'sub'))
WITH CHECK (uploaded_by = (SELECT auth.jwt() ->> 'sub'));
CREATE POLICY storage_objects_owner_delete ON storage.objects
FOR DELETE TO authenticated
USING (uploaded_by = (SELECT auth.jwt() ->> 'sub'));
GRANT SELECT, INSERT, UPDATE, DELETE ON storage.objects TO authenticated;
GRANT USAGE ON SCHEMA storage TO authenticated;
```
---
## Pattern: Public-read Bucket
**Use when:** Anyone (signed in or anonymous) should read files in the bucket, but only the owner can write or delete. Photo galleries, public assets, marketing content, user avatars served to everyone.
| Caller | SELECT | INSERT | UPDATE | DELETE |
|--------|--------|--------|--------|--------|
| Owner of the row | ✓ | ✓ | ✓ | ✓ |
| Other authenticated user | ✓ | ✗ | ✗ | ✗ |
| Anonymous (`anon`) | ✓ | ✗ | ✗ | ✗ |
```sql
ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS storage_objects_owner_select ON storage.objects;
CREATE POLICY storage_objects_public_read ON storage.objects
FOR SELECT TO authenticated, anon
USING (bucket = 'photos');
-- Writes / mutations stay owner-only (same as the owner-only pattern)
GRANT SELECT ON storage.objects TO anon;
GRANT USAGE ON SCHEMA storage TO anon;
```
Marking the bucket `public` does more than fast-path routing: direct object GETs on a public bucket are served with root access and **bypass `storage.objects` RLS entirely** — an anon SELECT policy does not gate those downloads. RLS still scopes list and metadata queries made through the user API, but it cannot restrict who fetches an object URL from a public bucket. Only mark a bucket `public` when every object in it may be world-readable. If visibility is conditional (e.g. files attached to draft content that becomes readable on publish), keep the bucket private and mint signed URLs after an RLS-scoped check — see Caveats below.
---
## Pattern: Path-scoped Bucket
**Use when:** Each user owns a folder named after their `sub`, and the first path segment encodes ownership. Slack-style file URLs (`<user_id>/2024/photo.png`), per-user document trees.
```sql
ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS storage_objects_owner_select ON storage.objects;
DROP POLICY IF EXISTS storage_objects_owner_insert ON storage.objects;
DROP POLICY IF EXISTS storage_objects_owner_update ON storage.objects;
DROP POLICY IF EXISTS storage_objects_owner_delete ON storage.objects;
CREATE POLICY storage_objects_path_select ON storage.objects
FOR SELECT TO authenticated
USING (
bucket = 'user-files'
AND (storage.foldername(key))[1] = (SELECT auth.jwt() ->> 'sub')
);
CREATE POLICY storage_objects_path_insert ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket = 'user-files'
AND (storage.foldername(key))[1] = (SELECT auth.jwt() ->> 'sub')
);
CREATE POLICY storage_objects_path_update ON storage.objects
FOR UPDATE TO authenticated
USING (bucket = 'user-files' AND (storage.foldername(key))[1] = (SELECT auth.jwt() ->> 'sub'))
WITH CHECK (bucket = 'user-files' AND (storage.foldername(key))[1] = (SELECT auth.jwt() ->> 'sub'));
CREATE POLICY storage_objects_path_delete ON storage.objects
FOR DELETE TO authenticated
USING (
bucket = 'user-files'
AND (storage.foldername(key))[1] = (SELECT auth.jwt() ->> 'sub')
);
```
A key with no `/` returns an empty array from `storage.foldername`, so `[1]` is NULL and the row is invisible to everyone except admin. If you want bucket-root files allowed, add `OR (storage.foldername(key))[1] IS NULL` to the relevant clause.
---
## Pattern: Team-shared Bucket
**Use when:** Files belong to teams / workspaces / organizations rather than individual users. Members of a team can read and write files attributed to that team; non-members can't see them.
The team-id is encoded as the first path segment (`<team_id>/<file>`). A membership table maps users to teams.
```sql
CREATE TABLE IF NOT EXISTS team_members (
team_id UUID NOT NULL,
user_id TEXT NOT NULL, -- TEXT so third-party auth subs work
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner','admin','member')),
joined_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (team_id, user_id)
);
CREATE INDEX IF NOT EXISTS team_members_user_idx ON team_members (user_id);
ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS storage_objects_owner_select ON storage.objects;
-- (drop the rest of the owner-only set too)
CREATE POLICY storage_objects_team_select ON storage.objects
FOR SELECT TO authenticated
USING (
bucket = 'team-files'
AND EXISTS (
SELECT 1 FROM team_members
WHERE team_members.team_id::text = (storage.foldername(key))[1]
AND team_members.user_id = (SELECT auth.jwt() ->> 'sub')
)
);
CREATE POLICY storage_objects_team_insert ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket = 'team-files'
AND uploaded_by = (SELECT auth.jwt() ->> 'sub')
AND EXISTS (
SELECT 1 FROM team_members
WHERE team_members.team_id::text = (storage.foldername(key))[1]
AND team_members.user_id = (SELECT auth.jwt() ->> 'sub')
)
);
-- UPDATE/DELETE: only the original uploader. Loosen for team admins as needed.
CREATE POLICY storage_objects_team_update ON storage.objects
FOR UPDATE TO authenticated
USING (uploaded_by = (SELECT auth.jwt() ->> 'sub'))
WITH CHECK (uploaded_by = (SELECT auth.jwt() ->> 'sub'));
CREATE POLICY storage_objects_team_delete ON storage.objects
FOR DELETE TO authenticated
USING (uploaded_by = (SELECT auth.jwt() ->> 'sub'));
```
For "admins can delete anything in their team" semantics, replace the DELETE policy's `uploaded_by =` check with a membership lookup that requires `role IN ('owner','admin')`.
---
## Pattern: Mixed REST + S3 Surfaces
InsForge exposes two write surfaces against the same `storage.objects` table:
| Surface | Who calls it | `uploaded_by` set to |
|---------|--------------|----------------------|
| `/api/storage/...` REST | A signed-in end user, JWT in the request | The caller's `sub` |
| `/storage/v1/s3/...` S3 protocol | An AWS-SDK / `aws-cli` client with an InsForge S3 access key | `NULL` |
Under the default owner-only SELECT policy, `NULL = '<sub>'` is never true (SQL three-valued logic), so **end users cannot see S3-uploaded rows through the user API**. Admin/API-key callers bypass RLS and see everything. The S3 surface itself doesn't run RLS — it uses admin credentials by design.
When the S3 gateway overwrites a key that a REST user previously owned, the platform preserves `uploaded_by` — it does not clobber to NULL. That part is automatic.
If end users need to see S3-uploaded rows, expose them explicitly:
```sql
DROP POLICY IF EXISTS storage_objects_owner_select ON storage.objects;
CREATE POLICY storage_objects_visible_select ON storage.objects
FOR SELECT TO authenticated
USING (
bucket = 'shared-content'
AND (
uploaded_by IS NULL -- S3-uploaded rows
OR uploaded_by = (SELECT auth.jwt() ->> 'sub') -- caller's own rows
)
);
```
If your S3 ingester needs to attribute ownership instead, run an admin-side `UPDATE storage.objects SET uploaded_by = $sub WHERE bucket = $1 AND key = $2 AND uploaded_by IS NULL;` after the upload — admin bypasses RLS so the UPDATE just works.
The cleanest answer is often to put REST-served and S3-served files in separate buckets so the `NULL = sub` foot-gun never comes up.
---
## Performance Best Practices
### Hoist `auth.jwt()` into a subquery
Without the `(SELECT ...)` wrap, `auth.jwt()` re-evaluates per row and list queries get slow:
```sql
-- SLOW: auth.jwt() called per row
USING (uploaded_by = auth.jwt() ->> 'sub')
-- FASTER: evaluated once per query
USING (uploaded_by = (SELECT auth.jwt() ->> 'sub'))
```
### Index the columns RLS reads
`storage.objects` already has indexes on `(bucket, key)` and `uploaded_by`. If you write path-based policies, a functional index on `(storage.foldername(key))[1]` helps for buckets with millions of objects. For team-shared buckets, the `team_members(user_id)` index from the schema is the one that gates request latency.
### Don't write app-side filters on top of RLS
Adding `WHERE uploaded_by = $1` in your service code on top of the RLS policy duplicates work and often hides bugs. Trust the policy; let RLS handle authorization.
---
## Caveats
- **Per-operation policies are independent.** A permissive SELECT does NOT grant DELETE. The reverse is also true. Audit each of the four operations separately.
- **Permissive vs restrictive policies.** Multiple matching policies OR together by default. If you want AND behavior, use `AS RESTRICTIVE`. Most storage policies are permissive (default).
- **Public buckets bypass RLS on downloads.** When `storage.buckets.public` is true, the object-visibility check runs with root access: anonymous GETs of object URLs succeed even if a SELECT policy would deny the row. RLS on `storage.objects` still scopes list/metadata queries through the user API, but it never gates direct downloads from a public bucket. Conditionally-visible files (drafts, gated content) belong in a private bucket with signed URLs issued only after your RLS or app-level check passes.
- **Out-of-band URLs bypass RLS.** Presigned S3 URLs and signed download links are redeemed against the storage backend directly — RLS does not fire on those redemptions. The platform code does an explicit RLS-scoped existence check before issuing the URL; if you build your own signed-URL flow, do the same.
- **Admin always sees everything.** RLS only applies to `authenticated` and `anon`. API-key callers and dashboard inspectors bypass policies regardless of which pattern you pick.
---
## Checklist
Before shipping a storage RLS configuration, apply `storage.objects` policy changes with a custom migration.
- [ ] `ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY` is in your policy migration — fresh installs ship with RLS disabled (since migration 047), and the statement is idempotent, so always include it. Verify with `SELECT relrowsecurity FROM pg_class WHERE oid = 'storage.objects'::regclass;`
- [ ] Policies target `storage.objects`
- [ ] All four operations (SELECT, INSERT, UPDATE, DELETE) have policies — or you've consciously decided to deny one
- [ ] `auth.jwt() ->> 'sub'` is wrapped in `(SELECT ...)` for performance
- [ ] `GRANT SELECT, INSERT, UPDATE, DELETE ON storage.objects TO authenticated` (and `anon` if your pattern allows it)
- [ ] `GRANT USAGE ON SCHEMA storage TO authenticated` (and `anon` if applicable)
- [ ] Buckets marked `public` contain only world-readable objects — direct GETs on a public bucket skip RLS, so conditionally-visible files live in a private bucket with signed URLs
- [ ] Mixed REST + S3 buckets either (a) live in separate buckets, (b) include `uploaded_by IS NULL OR ...` in the SELECT policy, or (c) attribute ownership in an admin-side UPDATE after S3 ingest
- [ ] Tested as `authenticated`, not as superuser/admin — `psql` by default connects with elevated rights
## References
- [PostgreSQL RLS Documentation](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- [Database access-control CLI reference](../../insforge-cli/references/database/access-control.md) — patterns for application tables, helper-function tricks, infinite-recursion gotchas
storage/s3-gateway.md›
# S3-Compatible Storage Gateway
> **Default to the SDK.** `@insforge/sdk` (see [sdk-integration.md](./sdk-integration.md)) is the recommended path for storage work — it is the supported default for app code, handles auth/session scoping, and avoids shipping long-lived project-admin credentials. Use the S3 gateway only when the consumer is existing S3 tooling (CI, `rclone`, Terraform, backup/log shippers) and adopting the SDK would be impractical.
InsForge Storage speaks the **AWS S3 protocol** at `/storage/v1/s3`. Any SigV4-signing client — `aws` CLI, AWS SDKs, `rclone`, Terraform, custom scripts — can read and write the same buckets exposed through the `@insforge/sdk`, REST API, and Dashboard.
> **Requires InsForge 2.0.9 or later.** The S3 gateway was introduced in 2.0.9; earlier versions do not expose `/storage/v1/s3` and the admin endpoints under `/api/storage/s3/*` return 404. Confirm the project's server version before configuring any S3 client — on Cloud, check the Dashboard footer or `GET /api/health`; self-hosted, check your deployment's image tag.
>
> **Cloud only.** The S3 gateway is available on InsForge Cloud projects. Self-hosted deployments can achieve the same capability by running the platform against a MinIO or AWS S3 bucket and exposing `/storage/v1/s3` behind their own ingress.
## When to use the S3 gateway (vs. the SDK)
Pick the S3 gateway when the consumer is **server-side tooling that already speaks S3**:
- CI/CD pipelines pushing build artifacts (`aws s3 cp`, `aws s3 sync`, `rclone sync`).
- Migrating existing S3-based automation (Terraform `aws_s3_object`, backup scripts, log shippers) without rewriting it.
- Server/worker uploads where embedding `@insforge/sdk` is overkill.
Prefer the [InsForge SDK](./sdk-integration.md) for browser direct uploads, public download URLs, bucket visibility management, and typed helpers — the SDK is built for app code and does not require handing out long-lived S3 credentials.
## Setup
### Endpoint and region
Both values are shown in the Dashboard under **Storage → Settings → S3 Configuration**, or fetched via `GET /api/storage/s3/config`.
| Field | Value |
| --- | --- |
| Endpoint | `https://{app-key}.{region}.insforge.app/storage/v1/s3` |
| Region | `us-east-2` (or the value set via `AWS_REGION`) |
Clients **must** use path-style URLs (`forcePathStyle: true` / `addressing_style = path` / `force_path_style = true`). Virtual-hosted style (`{bucket}.endpoint/...`) is not supported — configurations that omit path-style will fail with signature or DNS errors.
### Access keys
#### Create a key
Generate credentials from **Storage → Settings → S3 Configuration → New access key** in the Dashboard, or via the admin API:
```bash
curl -X POST "$API_BASE/api/storage/s3/access-keys" \
-H "x-api-key: $ACCESS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"description":"backup-script"}'
```
Response:
```json
{
"data": {
"id": "11111111-1111-1111-1111-111111111111",
"accessKeyId": "INSFABC123DEF456GH78",
"secretAccessKey": "x7K2-a_pL9qRs4N8vYzWcE1fH5gJ3mUtBoD6ViXk",
"description": "backup-script",
"createdAt": "2026-04-22T00:00:00Z",
"lastUsedAt": null
}
}
```
> **The `secretAccessKey` is returned exactly once.** It is encrypted at rest and cannot be recovered later. Capture it immediately on create; if lost, revoke and recreate the key.
#### Revoke a key
```bash
curl -X DELETE -H "x-api-key: $ACCESS_API_KEY" \
"$API_BASE/api/storage/s3/access-keys/$KEY_ID"
```
Revocation invalidates the server-side LRU cache immediately, so clients still holding the credentials start seeing `InvalidAccessKeyId` at once.
## Usage Examples
All examples assume:
- `endpoint` = `https://your-appkey.your-region.insforge.app/storage/v1/s3`
- `region` = `us-east-2`
- Path-style addressing enabled
### AWS SDK for JavaScript v3
```ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const client = new S3Client({
forcePathStyle: true,
region: 'us-east-2',
endpoint: 'https://your-appkey.your-region.insforge.app/storage/v1/s3',
credentials: {
accessKeyId: 'your_access_key_id',
secretAccessKey: 'your_secret_access_key',
},
});
await client.send(
new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'hello.txt',
Body: 'hello from node',
ContentType: 'text/plain',
})
);
```
### AWS CLI (shared credentials file)
```ini
# ~/.aws/credentials
[insforge]
aws_access_key_id = your_access_key_id
aws_secret_access_key = your_secret_access_key
# ~/.aws/config
[profile insforge]
region = us-east-2
endpoint_url = https://your-appkey.your-region.insforge.app/storage/v1/s3
s3 =
addressing_style = path
```
```bash
aws --profile insforge s3 ls
aws --profile insforge s3 cp ./photo.jpg s3://my-bucket/photo.jpg
aws --profile insforge s3 sync ./dist s3://my-bucket/dist
```
### boto3 (Python)
```python
import boto3
from botocore.config import Config
s3 = boto3.client(
's3',
region_name='us-east-2',
endpoint_url='https://your-appkey.your-region.insforge.app/storage/v1/s3',
aws_access_key_id='your_access_key_id',
aws_secret_access_key='your_secret_access_key',
config=Config(s3={'addressing_style': 'path'}),
)
s3.upload_file('photo.jpg', 'my-bucket', 'photo.jpg')
```
### rclone
```ini
# ~/.config/rclone/rclone.conf
[insforge]
type = s3
provider = Other
access_key_id = your_access_key_id
secret_access_key = your_secret_access_key
endpoint = https://your-appkey.your-region.insforge.app/storage/v1/s3
region = us-east-2
force_path_style = true
```
```bash
rclone copy ./dist insforge:my-bucket/dist
rclone sync insforge:my-bucket/backups ./local-backups
```
## Supported Operations
The gateway covers the operations common workloads depend on:
| Category | Operations |
| --- | --- |
| Bucket | `ListBuckets`, `CreateBucket`, `DeleteBucket`, `HeadBucket`, `ListObjectsV2` |
| Object | `PutObject`, `GetObject` (incl. `Range`), `HeadObject`, `DeleteObject`, `DeleteObjects`, `CopyObject` |
| Multipart | `CreateMultipartUpload`, `UploadPart`, `CompleteMultipartUpload`, `AbortMultipartUpload`, `ListParts` |
| Probe stubs | `GetBucketLocation`, `GetBucketVersioning` (for SDK startup checks) |
Streaming uploads (`STREAMING-AWS4-HMAC-SHA256-PAYLOAD`) are fully supported, so `aws s3 cp` with large files and `aws s3 sync` work without any client-side configuration changes.
## Shared Namespace with REST and SDK
An object uploaded via the S3 gateway appears immediately in the REST API, the SDK, and the Dashboard — and vice versa. There is no separate S3 bucket namespace to reconcile.
```bash
# Upload via S3 protocol
aws --profile insforge s3 cp photo.jpg s3://my-bucket/photo.jpg
# Same object is visible to the REST API
curl -H "x-api-key: $ACCESS_API_KEY" \
"$API_BASE/api/storage/buckets/my-bucket/objects"
# { "data": [{ "key": "photo.jpg", ... }] }
```
This means buckets created via `npx -y @insforge/cli storage create-bucket` are reachable over S3, and objects uploaded with `aws s3 cp` can be served via the SDK's `download()` using the same key.
## Best Practices
- **Treat S3 keys like production credentials.** They grant project-admin-level access — every bucket is readable and writable regardless of the `public`/`private` flag. Never ship them to browsers, never commit them to source control.
- **Scope keys per workload.** Create distinct keys for CI, backups, and each automation tool so you can rotate or revoke them independently. Stay under the project cap of **50 keys**.
- **Rotate on schedule.** Revoking via `DELETE /api/storage/s3/access-keys/{id}` invalidates the server-side LRU cache immediately.
- **Always enable path-style addressing** on every client (`forcePathStyle: true` / `addressing_style = path` / `force_path_style = true`).
- **Capture `secretAccessKey` at creation time.** It is encrypted at rest and cannot be recovered; if lost, revoke and recreate the key.
- **Prefer the SDK for browser flows, public download URLs, bucket visibility changes, and typed helpers** — the gateway is for server-side S3 tooling, not app code.
## Common Mistakes
### Unsupported features
Reach for the matching alternative instead of trying to make these work:
- **Presigned URLs** (query-string SigV4). For browser direct uploads, use the REST helper `POST /api/storage/buckets/:bucket/upload-strategy`, or the SDK's `uploadAuto()` / `upload()`.
- **Session tokens** (`X-Amz-Security-Token`, user-JWT-scoped access). The gateway accepts only the long-lived access keys described above.
- **S3 governance features**: versioning, SSE-C / SSE-KMS, bucket policies, ACLs, object lock, tagging, lifecycle, replication, CORS config. These return `NotImplemented` (501).
- **Virtual-hosted-style URLs**. Path-style only.
### Configuration pitfalls
| Mistake | Solution |
|---------|----------|
| Using virtual-hosted style (`{bucket}.endpoint/...`) | Enable path-style addressing on every client |
| Losing the `secretAccessKey` after creation | Capture it at creation time; revoke and recreate if lost |
| Shipping access keys to a browser | Use the SDK or upload-strategy REST helper instead — S3 keys are project-admin |
| Expecting presigned URLs to work | Use `POST /api/storage/buckets/:bucket/upload-strategy` for browser direct uploads |
| Trying to set bucket policies / lifecycle / CORS via S3 | Not supported — manage visibility via the Dashboard / CLI |
## Recommended Workflow
```text
1. Generate an access key → POST /api/storage/s3/access-keys (capture secret once)
2. Configure the client → endpoint + region + path-style
3. Verify connectivity → aws --profile insforge s3 ls
4. Use standard S3 tooling → cp / sync / multipart uploads / Terraform
5. Rotate or revoke keys → DELETE /api/storage/s3/access-keys/{id}
```
storage/sdk-integration.md›
# Storage SDK Integration
Use InsForge SDK to upload, download, and manage files in your frontend application.
> **Recommended path.** Prefer `@insforge/sdk` for all storage work — it is the supported default for app code (browser and server), handles auth/session scoping, and keeps project-admin credentials in backend/admin tooling. Reach for the [S3-compatible gateway](./s3-gateway.md) only when the consumer is existing S3 tooling (CI pipelines running `aws s3 cp` / `rclone sync`, Terraform, backup/log shippers) where adopting the SDK would be impractical.
## Setup
First, ensure your `.env` file is configured with your InsForge URL and anon key. Get the anon key with `npx -y @insforge/cli secrets get ANON_KEY`. See the main [SKILL.md](../SKILL.md) for framework-specific variable names and full setup steps.
```javascript
import { createClient } from '@insforge/sdk'
const insforge = createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL, // adjust prefix for your framework
anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY // adjust prefix for your framework
})
```
For Next.js / SSR Client Components, use the SSR browser client so direct browser uploads have the user's access token:
```typescript
import { createBrowserClient } from '@insforge/sdk/ssr'
const insforge = createBrowserClient()
```
Use `createBrowserClient()` for authenticated browser uploads. It reads the browser-readable `insforge_access_token` cookie and refreshes through `/api/auth/refresh`; the refresh token remains httpOnly.
## Upload File
Upload with specific path/key.
```javascript
const { data, error } = await insforge.storage
.from('images')
.upload('posts/post-123/cover.jpg', fileObject)
// IMPORTANT: Save BOTH url and key to database
await insforge.database
.from('posts')
.update({
image_url: data.url,
image_key: data.key // Required for download/delete
})
.eq('id', 'post-123')
```
## Upload with Auto-Generated Key
```javascript
const { data, error } = await insforge.storage
.from('uploads')
.uploadAuto(fileObject)
// data.key: "myfile-1705315200000-abc123.jpg"
```
## Download File
```javascript
// Get key from database
const { data: post } = await insforge.database
.from('posts')
.select('image_key')
.eq('id', 'post-123')
.single()
// Download using key
const { data: blob, error } = await insforge.storage
.from('images')
.download(post.image_key)
const url = URL.createObjectURL(blob)
```
## Delete File
```javascript
const { data, error } = await insforge.storage
.from('images')
.remove(post.image_key)
// Clear database reference
await insforge.database
.from('posts')
.update({ image_url: null, image_key: null })
.eq('id', 'post-123')
```
## Important Notes
- **Always save both `url` AND `key`**: The URL is for display; the key is required for download/delete operations
- All methods return `{ data, error }` - always check for errors
- Bucket must exist before uploading (create via admin API)
- In Next.js / SSR apps, direct browser uploads should use `createBrowserClient()` from `@insforge/sdk/ssr` so Storage RLS sees the signed-in user
---
## Best Practices
1. **Verify bucket exists before uploading**
- Check available buckets via CLI: `insforge storage buckets`
- If no buckets exist, create one first via admin API
2. **Always store both URL and key**
- The `url` is for displaying/embedding files
- The `key` is required for download and delete operations
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Uploading before the bucket exists | Verify the bucket via admin API before uploading |
| Saving only the returned URL | Save both `data.url` and `data.key` |
| Using the URL for download/delete operations | Use the stored `key` |
| Creating a plain browser client in SSR Client Components | Use `createBrowserClient()` so access refresh flows through `/api/auth/refresh` and the refresh token remains httpOnly |
## Recommended Workflow
```
1. Check available buckets → insforge storage buckets
2. If no bucket exists → Create one first
3. Upload file → Save both url and key to database
4. Display file → Use url
5. Download/Delete → Use key
```