Skip to main content

Entitlements & Authorization

GospeLib uses a freemium subscription model with three tiers. Entitlements control which features each user can access, enforced at the gateway level.

Plan Tiers

TierPriceAudienceKey Features
Reader (free)$0All membersScripture reading, basic search, Topical Guide
Scholar$79.99/yearSerious students, educators+ Interlinear, witnesses, scholarly commentary, graph explorer, AI
Academic$149.99/yearResearchers, faculty+ All features, higher rate limits

Entitlement Map

Entitlements are defined in services/billing/config/plans.yaml:

EntitlementReaderScholarAcademic
scriptures_read
basic_search
topical_guide_browse
interlinear_hebrew_greek
manuscript_witnesses
scholarly_commentary
knowledge_graph_explorer
cross_references_advanced
ai_features

Academic tier has "*" (all entitlements) plus higher rate limits.

Rate Limit Tiers

ResourceFreeScholarAcademic
AI requests5/hour50/hour200/hour
Search20/min200/min1000/min
Passages60/min600/min600/min

How Entitlements Are Enforced

sequenceDiagram
participant App
participant GW as Gateway
participant Redis
participant Billing as Billing Service

Note over Billing,Redis: At startup + every 60s
Billing->>Redis: Cache entitlements per plan

App->>GW: GET /api/v1/ai/explain
GW->>GW: Extract X-User-Plan from JWT claims
GW->>Redis: GET gl:entitlements:scholar:ai_features
Redis-->>GW: "1" (allowed)
GW->>GW: Proceed to proxy

Key Design Decisions

  1. Entitlements are cached in Redis (60-second TTL) — the gateway never calls the billing service in the hot path
  2. Cache key format: gl:entitlements:<planId>:<feature>"1" or "0"
  3. O(1) lookup — a single Redis GET per request, not a service call
  4. Billing service owns the source of truth — plan configurations live in plans.yaml and are pushed to Redis at startup and on subscription changes

Gateway Entitlement Middleware

The gateway uses middleware to gate routes by required entitlement:

r.Group(func(r chi.Router) {
r.Use(entitlement.Require("ai_features"))
r.Mount("/api/v1/ai", proxy.To(cfg.AIServiceURL))
})

If the user's plan doesn't include the required entitlement, the gateway returns a 403 Forbidden with a descriptive error.

Stripe Subscription Mapping

Stripe manages the billing side. Plan changes flow through webhooks:

sequenceDiagram
participant User
participant Stripe
participant Billing as Billing Service
participant PG as PostgreSQL
participant Redis

User->>Stripe: Subscribe to Scholar plan
Stripe->>Billing: Webhook: subscription.created
Billing->>Billing: Verify Stripe signature
Billing->>PG: Check gl_stripe_events (idempotency)
Billing->>PG: INSERT gl_subscriptions
Billing->>PG: UPDATE gl_users SET plan_id = 'scholar'
Billing->>Redis: Refresh entitlement cache
Billing->>PG: INSERT gl_stripe_events (mark processed)

Webhook Idempotency

Every Stripe webhook event is checked against gl_stripe_events before processing. If the stripe_event_id already exists, the webhook returns {"status": "already_processed"} and takes no action.

Stripe Configuration

Plan pricing is stored as config (not hardcoded):

# services/billing/config/plans.yaml
plans:
scholar:
stripe_price_id: '${STRIPE_PRICE_SCHOLAR_MONTHLY}'
stripe_price_annual_id: '${STRIPE_PRICE_SCHOLAR_ANNUAL}'
price_monthly_usd: 799 # $7.99
academic:
stripe_price_id: '${STRIPE_PRICE_ACADEMIC_MONTHLY}'
price_monthly_usd: 1499 # $14.99

Price IDs and product IDs are environment variables — test mode keys in staging, live keys in production.