Core Concepts

Version
This page reflects Corral v0.4.1 (agent-native auth + billing CLI).

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

ConcernHandled byConfigured in
OAuth flows (Google, GitHub…)Better Authcorral.yaml β†’ compiled to BA config
Session storage + TTLBetter Authauth.session_ttl
Magic link / OTP emailBetter Authauth.providers.email
Passkeys (WebAuthn)Better Authauth.providers: [passkey]
User ↔ plan mappingCorralplans:
Stripe checkout + webhooksCorralplans.*.price
Feature gate evaluationCorralfeatures:
Usage meter trackingCorralmeters:
Device auth (CLI OAuth)Corralauth.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:

typescript
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
}
Anonymous sessions
Unauthenticated requests still get a session object, but with 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:

corral.yamlyaml
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::

ModeWhat the user seesBest for
hideFeature completely absent from UIAdmin-only features
blurContent blurred with upgrade CTA overlayPremium content preview
skeletonLoading skeleton with "upgrade to unlock" hintReal-time data, charts
blockHard block: full-width upgrade promptHigh-value gated pages
redirectRedirect to gate.redirect_to URLServer-side pages
disabledButton/control visible but disabled with tooltipActions 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.

corral.yamlyaml
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:

typescript
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

OverageBehavior
blockcheckLimit() returns { ok: false }. You must handle the block.
warncheckLimit() returns { ok: true, warning: true, percent: 105 }.
chargeOverage 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

  1. CLI calls POST /api/auth/device/authorize β†’ gets a device_code and a user_code
  2. CLI prints: "Open https://horizon.mil/auth/activate and enter code: ABCD-1234"
  3. User opens browser, logs in (if not already), enters the code
  4. CLI polls POST /api/auth/device/token until approved
  5. CLI receives an access token + refresh token
  6. All subsequent CLI requests include Authorization: Bearer <token>
typescripthorizon-cli/auth.ts
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}` }
})
Enable device auth
Add 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.

Anonymous
Not signed in
No session. Features gated to auth or above require sign-in. Gate mode = redirect /login by default.
Free
Signed in, free plan
Has a session. Access to features in the free plan. Gate mode from YAML for blocked features.
Pro
Signed in, paid plan
Active Stripe subscription. Access to all features in pro (and inherited free). Meter limits apply.
Admin
Admin role
Role-based, not plan-based. Can access roles: [admin] features regardless of plan. Set via corral users set-plan / admin role management in generated AdminPanel.

Evaluation order

  1. Authentication check β€” Is there a valid session?
  2. Role check β€” Does the feature require a role? Does the user have it?
  3. Plan check β€” Is the user's plan in the feature's plans list?
  4. Subscription status check β€” Is the subscription active/trialing (not canceled/past_due)?
  5. Meter check β€” Has the user exceeded their usage limit for this meter?
  6. 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

SectionDescription
appName, URL, description
authProviders, session TTL, 2FA settings
plansPlan names, prices, trial days
featuresFeature names, plan membership, gate modes
metersUsage counters, per-plan limits, overage behavior
databaseAdapter, connection URL
adminAdmin page path, access roles
webhooksStripe webhook events to handle
emailsEmail 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.

corral.yamlyaml
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