Core Concepts
Corral is built on a small set of composable primitives. Understand these and the entire system becomes predictable.
Auth β Better Auth under the hood
Corral wraps Better Auth β a TypeScript-first auth library that handles sessions, OAuth, magic links, passkeys, TOTP, and more. Corral adds billing and gating on top without replacing or forking Better Auth.
What Corral handles vs. what Better Auth handles
| Concern | Handled by | Configured in |
|---|---|---|
| OAuth flows (Google, GitHubβ¦) | Better Auth | corral.yaml β compiled to BA config |
| Session storage + TTL | Better Auth | auth.session_ttl |
| Magic link / OTP email | Better Auth | auth.providers.email |
| Passkeys (WebAuthn) | Better Auth | auth.providers: [passkey] |
| User β plan mapping | Corral | plans: |
| Stripe checkout + webhooks | Corral | plans.*.price |
| Feature gate evaluation | Corral | features: |
| Usage meter tracking | Corral | meters: |
| Device auth (CLI OAuth) | Corral | auth.providers: [device] |
Session model
Every signed-in request carries a session cookie (or Bearer token for API calls). The session contains a user ID only β plan and feature data are fetched from the database on each request and cached per-session in memory (configurable TTL, default 60s).
The session object your code receives:
interface CorralSession {
user: {
id: string
email: string
name?: string
image?: string
role: 'user' | 'admin'
createdAt: Date
}
plan: {
id: 'free' | 'pro' | 'enterprise' // matches corral.yaml
status: 'active' | 'trialing' | 'past_due' | 'canceled'
trialEndsAt?: Date
currentPeriodEnd?: Date
}
features: string[] // ['basic_search', 'export_csv', 'ai_analysis', ...]
limits: Record<string, number> // meter limits for this plan
}
user: null, plan: { id: 'anonymous' },
and an empty features array. This lets you gate features
for anonymous vs. logged-in users consistently.
Plans & Features
Plans are the subscription tiers of your product. Features are
named capabilities that belong to one or more plans.
The mapping lives entirely in corral.yaml.
Plan hierarchy
Plans are ordered in corral.yaml. Higher plans inherit all
features of lower plans unless you opt out:
plans:
free:
price: 0
pro:
price: 49
inherits: free # optional; default is true for ordered plans
enterprise:
price: custom
inherits: pro
features:
basic_search:
plans: [free, pro, enterprise]
ai_analysis:
plans: [pro, enterprise]
gate: blur
bulk_export:
plans: [enterprise]
gate: block
Gate modes
When a user without access encounters a gated feature, Corral can respond
in several ways, controlled by gate::
| Mode | What the user sees | Best for |
|---|---|---|
hide | Feature completely absent from UI | Admin-only features |
blur | Content blurred with upgrade CTA overlay | Premium content preview |
skeleton | Loading skeleton with "upgrade to unlock" hint | Real-time data, charts |
block | Hard block: full-width upgrade prompt | High-value gated pages |
redirect | Redirect to gate.redirect_to URL | Server-side pages |
disabled | Button/control visible but disabled with tooltip | Actions in forms |
Usage Meters
Meters track consumption of a resource (API calls, exported rows, AI tokens,
storage bytes) and enforce per-plan limits. They're defined in
corral.yaml and stored in your database.
meters:
api_calls:
plans:
free: 500
pro: 10000
enterprise: unlimited
window: month # rolling 30 days, or calendar month
overage: block # block | warn | charge
export_rows:
plans:
free: 1000
pro: 100000
window: month
overage: warn # show warning but allow
Tracking usage
Call the trackUsage function on the server side wherever a
metered action occurs:
import { trackUsage, checkLimit } from '@corral/core'
// In a server action or API route:
async function runAIAnalysis(userId: string, payload: AnalysisRequest) {
// Check before running
const allowed = await checkLimit(userId, 'api_calls')
if (!allowed.ok) {
throw new Error(`API limit reached. Resets ${allowed.resetsAt}.`)
}
// Do the work
const result = await doAnalysis(payload)
// Record usage
await trackUsage(userId, 'api_calls', 1)
return result
}
Overage behaviors
| Overage | Behavior |
|---|---|
block | checkLimit() returns { ok: false }. You must handle the block. |
warn | checkLimit() returns { ok: true, warning: true, percent: 105 }. |
charge | Overage billed via Stripe metered billing. Requires Stripe setup. |
Device Auth (CLI β Browser β Token)
Device auth lets a CLI tool or non-browser environment authenticate a user via their browser. It follows the OAuth 2.0 Device Authorization Grant (RFC 8628).
HORIZON ships with a CLI tool (horizon-cli) that analysts
use to query the API from their terminal. Device auth lets them
log in to the web app once and have the CLI automatically receive a token.
The flow
- CLI calls
POST /api/auth/device/authorizeβ gets adevice_codeand auser_code - CLI prints: "Open https://horizon.mil/auth/activate and enter code: ABCD-1234"
- User opens browser, logs in (if not already), enters the code
- CLI polls
POST /api/auth/device/tokenuntil approved - CLI receives an access token + refresh token
- All subsequent CLI requests include
Authorization: Bearer <token>
import { deviceAuth } from '@corral/client'
const auth = new deviceAuth({ baseUrl: 'https://horizon.mil' })
// Start the flow
const { userCode, verificationUri } = await auth.start()
console.log(`Open ${verificationUri} and enter: ${userCode}`)
// Poll until approved (or expired)
const token = await auth.poll() // blocks until user approves
console.log('Authenticated!')
// Use the token
const response = await fetch('https://horizon.mil/api/intel', {
headers: { Authorization: `Bearer ${token.access_token}` }
})
device to your auth providers in corral.yaml,
then run corral add device-verify --page /auth/activate to
generate the approval page.
The Gating Hierarchy
Corral's gate evaluator checks access in a strict order. The first failing check determines the gate response.
auth or above require sign-in. Gate mode = redirect /login by default.free plan. Gate mode from YAML for blocked features.pro (and inherited free). Meter limits apply.roles: [admin] features regardless of plan. Set via corral users set-plan / admin role management in generated AdminPanel.Evaluation order
- Authentication check β Is there a valid session?
- Role check β Does the feature require a role? Does the user have it?
- Plan check β Is the user's plan in the feature's
planslist? - Subscription status check β Is the subscription active/trialing (not canceled/past_due)?
- Meter check β Has the user exceeded their usage limit for this meter?
- Granted β All checks pass; feature is accessible.
The Config File
corral.yaml is the single source of truth. It's checked into version
control and treated as infrastructure-as-code. Changing the file and restarting
the app automatically applies the changes. corral validate checks
for inconsistencies before deploy.
Top-level sections
| Section | Description |
|---|---|
app | Name, URL, description |
auth | Providers, session TTL, 2FA settings |
plans | Plan names, prices, trial days |
features | Feature names, plan membership, gate modes |
meters | Usage counters, per-plan limits, overage behavior |
database | Adapter, connection URL |
admin | Admin page path, access roles |
webhooks | Stripe webhook events to handle |
emails | Email templates and SMTP config |
All values can reference environment variables with env:VAR_NAME syntax.
This keeps secrets out of the YAML file while keeping the structure readable.
database:
adapter: postgres
url: env:DATABASE_URL # reads process.env.DATABASE_URL
auth:
providers:
- google:
client_id: env:GOOGLE_CLIENT_ID
client_secret: env:GOOGLE_CLIENT_SECRET