/** * Entitlement queries. * * Two versions: * - getEntitlementsForUser (public query): for frontend ConvexClient subscription. * Derives the subject from Convex auth and returns free-tier defaults when * unauthenticated. * - getEntitlementsByUserId (internal query): for the gateway ConvexHttpClient * cache-miss fallback. Trusted server-to-server call with no auth gap. */ import type { QueryCtx } from "./_generated/server"; import { query, internalQuery } from "./_generated/server"; import { v } from "convex/values"; import { getFeaturesForPlan } from "./lib/entitlements"; import { resolveUserId } from "./lib/auth"; const FREE_TIER_DEFAULTS = { planKey: "free" as const, features: getFeaturesForPlan("free"), validUntil: 0, }; /** Shared handler logic for both public and internal queries. */ async function getEntitlementsHandler( ctx: QueryCtx, userId: string, ) { const entitlement = await ctx.db .query("entitlements") .withIndex("by_userId", (q) => q.eq("userId", userId)) .first(); if (!entitlement) { return FREE_TIER_DEFAULTS; } // Expired entitlements fall back to free tier (Pitfall 7 from research) if (entitlement.validUntil < Date.now()) { return FREE_TIER_DEFAULTS; } // Read-time merge with the canonical product catalog so feature flags added // to PRODUCT_CATALOG since the row was last written by the Dodo webhook // are surfaced immediately — no need to wait for the next subscription // event to rewrite the row. Stored row's `features` win on conflict // (preserves any per-user overrides). New fields the row lacks (e.g. // `mcpAccess` post-plan-2026-05-10-001 U10) inherit the catalog default // for the user's plan. const catalogDefaults = getFeaturesForPlan(entitlement.planKey); return { planKey: entitlement.planKey, features: { ...catalogDefaults, ...entitlement.features }, validUntil: entitlement.validUntil, }; } /** * Public query: returns entitlements for the authenticated user. * * Derives the caller from server-side auth identity. Unauthenticated * callers get free-tier defaults instead of arbitrary cross-user reads. */ export const getEntitlementsForUser = query({ args: {}, handler: async (ctx) => { const userId = await resolveUserId(ctx); if (!userId) { return FREE_TIER_DEFAULTS; } return getEntitlementsHandler(ctx, userId); }, }); /** * Internal query: returns entitlements for a given userId. * * Used by the gateway ConvexHttpClient for cache-miss fallback. * Trusted server-to-server call — no auth gap. */ export const getEntitlementsByUserId = internalQuery({ args: { userId: v.string() }, handler: async (ctx, args) => { return getEntitlementsHandler(ctx, args.userId); }, });