diff --git a/src/LLMProviders/brevilabsClient.ts b/src/LLMProviders/brevilabsClient.ts index 11c46e9b..39500ca1 100644 --- a/src/LLMProviders/brevilabsClient.ts +++ b/src/LLMProviders/brevilabsClient.ts @@ -2,7 +2,7 @@ import { BREVILABS_API_BASE_URL } from "@/constants"; import { getDecryptedKey } from "@/encryptionService"; import { MissingPlusLicenseError } from "@/error"; import { logInfo } from "@/logger"; -import { turnOffPlus, turnOnPlus } from "@/plusUtils"; +import { applyEntitlement, markPaidPendingEntitlement, turnOffPaid, turnOnPaid } from "@/plusUtils"; import { getSettings } from "@/settings/model"; import { arrayBufferToBase64 } from "@/utils/base64"; import { App, requestUrl } from "obsidian"; @@ -81,7 +81,13 @@ function parseBrevilabsResponse( } return { data: null, error: new Error(`HTTP error: ${response.status}`) }; } - logInfo(`[API ${endpoint} request]:`, data); + // Redact the signed entitlement JWS so it never lands in the shared + // copilot-log.md when a license response is logged. + const loggable = + data && typeof data === "object" && "entitlement" in data + ? { ...(data as Record), entitlement: "[redacted]" } + : data; + logInfo(`[API ${endpoint} request]:`, loggable); return { data: data as T }; } @@ -149,6 +155,8 @@ export interface Twitter4llmResponse { export interface LicenseResponse { is_valid: boolean; plan: string; + /** Signed entitlement token (JWS). Absent on servers that predate token issuance. */ + entitlement?: string; } export class BrevilabsClient { @@ -247,7 +255,10 @@ export class BrevilabsClient { } /** - * Validate the license key and update the isPlusUser setting. + * Validate the license key and update the entitlement flags (isPaidUser / + * isPlusUser). When the server returns a signed entitlement token, the tier is + * derived from it; otherwise the no-token fallback marks any valid license as + * paid + Plus (safe until Lite/Pro ship with tokens). * @param context Optional context object containing the features that the user is using to validate the license key. * @returns true if the license key is valid, false if the license key is invalid, and undefined if * unknown error. @@ -290,13 +301,26 @@ export class BrevilabsClient { if (error) { if (error.message === "Invalid license key") { - turnOffPlus(app); + turnOffPaid(app); return { isValid: false }; } // Do nothing if the error is not about the invalid license key return { isValid: undefined }; } - turnOnPlus(); + if (data?.entitlement) { + // Signed token present: derive tier (Plus vs Lite) from its claims. If it + // can't be verified (keys not shipped yet, kid rotation, clock skew), grant + // paid so general Plus features keep working, but withhold the strict gate — + // never grant multi-agent on an unverifiable token (an unverifiable Lite + // token must not bypass the gate), and never downgrade a confirmed license. + const verified = await applyEntitlement(data.entitlement); + if (!verified) { + markPaidPendingEntitlement(); + } + } else { + // Pre-token server: any valid license is paid + Plus (no Lite tier yet). + turnOnPaid(); + } return { isValid: true, plan: data?.plan }; } diff --git a/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts b/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts index 3edf8f86..e818db94 100644 --- a/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts +++ b/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts @@ -2,7 +2,7 @@ import { AGENT_LOOP_TIMEOUT_MS } from "@/constants"; import { MessageContent } from "@/imageProcessing/imageProcessor"; import { logError, logInfo, logWarn } from "@/logger"; import { UserMemoryManager } from "@/memory/UserMemoryManager"; -import { checkIsPlusUser } from "@/plusUtils"; +import { checkIsPaidUser } from "@/plusUtils"; import { getSettings } from "@/settings/model"; import { getSystemPromptWithMemory } from "@/system-prompts/systemPromptBuilder"; import { initializeBuiltinTools } from "@/tools/builtinTools"; @@ -386,7 +386,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner { this.llmFormattedMessages = []; this.lastDisplayedContent = ""; - const isPlusUser = await checkIsPlusUser(this.chainManager.app, { + const isPaidUser = await checkIsPaidUser(this.chainManager.app, { isAutonomousAgent: true, }); @@ -395,7 +395,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner { // Agent mode should never show thinking tokens in the response const thinkStreamer = new ThinkBlockStreamer(updateCurrentAiMessage, true); - if (!isPlusUser) { + if (!isPaidUser) { await this.handleError( new Error("Invalid license key"), thinkStreamer.processErrorChunk.bind(thinkStreamer) as (message: string) => void diff --git a/src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts b/src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts index e3a2eb7f..c91cd2b6 100644 --- a/src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts +++ b/src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts @@ -14,7 +14,7 @@ import { MessageContent, } from "@/imageProcessing/imageProcessor"; import { logInfo, logWarn } from "@/logger"; -import { checkIsPlusUser } from "@/plusUtils"; +import { checkIsPaidUser } from "@/plusUtils"; import { getSettings } from "@/settings/model"; import { getSystemPromptWithMemory } from "@/system-prompts/systemPromptBuilder"; import { createWriteFileTool } from "@/tools/ComposerTools"; @@ -767,10 +767,10 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`; const thinkStreamer = new ThinkBlockStreamer(updateCurrentAiMessage, excludeThinking); let sources: { title: string; path: string; score: number; explanation?: unknown }[] = []; - const isPlusUser = await checkIsPlusUser(this.chainManager.app, { + const isPaidUser = await checkIsPaidUser(this.chainManager.app, { isCopilotPlus: true, }); - if (!isPlusUser) { + if (!isPaidUser) { await this.handleError( new Error("Invalid license key"), thinkStreamer.processErrorChunk.bind(thinkStreamer) as (message: string) => void diff --git a/src/LLMProviders/chainRunner/utils/toolExecution.test.ts b/src/LLMProviders/chainRunner/utils/toolExecution.test.ts index 61e22ca0..37102438 100644 --- a/src/LLMProviders/chainRunner/utils/toolExecution.test.ts +++ b/src/LLMProviders/chainRunner/utils/toolExecution.test.ts @@ -5,7 +5,7 @@ import { z } from "zod"; // Mock dependencies jest.mock("@/plusUtils", () => ({ - checkIsPlusUser: jest.fn(), + checkIsPaidUser: jest.fn(), isSelfHostModeValid: jest.fn().mockReturnValue(false), })); @@ -21,11 +21,11 @@ jest.mock("@/tools/toolManager", () => ({ }, })); -import { checkIsPlusUser } from "@/plusUtils"; +import { checkIsPaidUser } from "@/plusUtils"; import { ToolManager } from "@/tools/toolManager"; describe("toolExecution", () => { - const mockCheckIsPlusUser = checkIsPlusUser as jest.MockedFunction; + const mockCheckIsPaidUser = checkIsPaidUser as jest.MockedFunction; const mockCallTool = ToolManager.callTool as jest.MockedFunction; beforeEach(() => { @@ -66,7 +66,7 @@ describe("toolExecution", () => { result: "Tool executed successfully", success: true, }); - expect(mockCheckIsPlusUser).not.toHaveBeenCalled(); + expect(mockCheckIsPaidUser).not.toHaveBeenCalled(); }); it("should block plus-only tools for non-plus users", async () => { @@ -89,7 +89,7 @@ describe("toolExecution", () => { }, }); - mockCheckIsPlusUser.mockResolvedValueOnce(false); + mockCheckIsPaidUser.mockResolvedValueOnce(false); const result = await executeSequentialToolCall({ name: "plusTool", args: {} }, [plusTool]); @@ -121,7 +121,7 @@ describe("toolExecution", () => { }, }); - mockCheckIsPlusUser.mockResolvedValueOnce(true); + mockCheckIsPaidUser.mockResolvedValueOnce(true); mockCallTool.mockResolvedValueOnce("Plus tool executed"); const result = await executeSequentialToolCall({ name: "plusTool", args: {} }, [plusTool]); @@ -131,7 +131,7 @@ describe("toolExecution", () => { result: "Plus tool executed", success: true, }); - expect(mockCheckIsPlusUser).toHaveBeenCalled(); + expect(mockCheckIsPaidUser).toHaveBeenCalled(); expect(mockCallTool).toHaveBeenCalled(); }); diff --git a/src/LLMProviders/chainRunner/utils/toolExecution.ts b/src/LLMProviders/chainRunner/utils/toolExecution.ts index 2a60b79d..12ff7d61 100644 --- a/src/LLMProviders/chainRunner/utils/toolExecution.ts +++ b/src/LLMProviders/chainRunner/utils/toolExecution.ts @@ -1,6 +1,6 @@ import { StructuredTool } from "@langchain/core/tools"; import { logError, logInfo, logWarn } from "@/logger"; -import { checkIsPlusUser, isSelfHostModeValid } from "@/plusUtils"; +import { checkIsPaidUser, isSelfHostModeValid } from "@/plusUtils"; import { getSettings } from "@/settings/model"; import { ToolManager } from "@/tools/toolManager"; import { ToolRegistry } from "@/tools/ToolRegistry"; @@ -64,8 +64,8 @@ export async function executeSequentialToolCall( // Check if tool requires Plus subscription if (metadata?.isPlusOnly) { - const isPlusUser = await checkIsPlusUser(); - if (!isPlusUser && !isSelfHostModeValid()) { + const isPaidUser = await checkIsPaidUser(); + if (!isPaidUser && !isSelfHostModeValid()) { return { toolName: toolCall.name, result: `Error: ${getToolDisplayName(toolCall.name)} requires a Copilot Plus subscription`, diff --git a/src/LLMProviders/chatModelManager.ts b/src/LLMProviders/chatModelManager.ts index a53cf298..9f58de34 100644 --- a/src/LLMProviders/chatModelManager.ts +++ b/src/LLMProviders/chatModelManager.ts @@ -9,7 +9,7 @@ import { } from "@/constants"; import { getDecryptedKey } from "@/encryptionService"; import { logError, logInfo } from "@/logger"; -import { isPlusEnabled } from "@/plusUtils"; +import { isPaidEnabled } from "@/plusUtils"; import { CopilotSettings, getModelKeyFromModel, @@ -813,7 +813,7 @@ export default class ChatModelManager { } // Check Copilot Plus entitlement requirements (bypassed in self-host mode) - if (model.plusExclusive && !isPlusEnabled()) { + if (model.plusExclusive && !isPaidEnabled()) { return false; } diff --git a/src/LLMProviders/embeddingManager.ts b/src/LLMProviders/embeddingManager.ts index 6e52e796..e735c0e8 100644 --- a/src/LLMProviders/embeddingManager.ts +++ b/src/LLMProviders/embeddingManager.ts @@ -147,7 +147,7 @@ export default class EmbeddingManager { const customModel = this.getCustomModel(embeddingModelKey); // Check if model is plus-exclusive but user is not a plus user - if (customModel.plusExclusive && !getSettings().isPlusUser) { + if (customModel.plusExclusive && !getSettings().isPaidUser) { new Notice("Plus-only model, please consider upgrading to Plus to access it."); throw new CustomError("Plus-only model selected but user is not on Plus plan"); } diff --git a/src/agentMode/backends/shared/agentSystemPrompt.test.ts b/src/agentMode/backends/shared/agentSystemPrompt.test.ts index 122ac60e..8bb6044a 100644 --- a/src/agentMode/backends/shared/agentSystemPrompt.test.ts +++ b/src/agentMode/backends/shared/agentSystemPrompt.test.ts @@ -95,7 +95,7 @@ describe("buildAgentSystemPrompt", () => { it("steers toward the builtin Copilot Plus skills regardless of Plus status", () => { // Default settings → NOT a Plus user; steering must still be present so a - // self-host user (Plus-enabled but isPlusUser=false) gets it, and non-Plus + // self-host user (Plus-enabled but isPaidUser=false) gets it, and non-Plus // users fall back to their own tools via the steering's fallback clause. const nonPlus = buildAgentSystemPrompt(); expect(nonPlus).toContain(COPILOT_PLUS_TOOLS_STEERING); @@ -113,7 +113,7 @@ describe("buildAgentSystemPrompt", () => { expect(nonPlus).toMatch(/fails for this particular request/i); // A Plus user gets the same steering. - updateSetting("isPlusUser", true); + updateSetting("isPaidUser", true); expect(buildAgentSystemPrompt()).toContain(COPILOT_PLUS_TOOLS_STEERING); }); diff --git a/src/agentMode/backends/shared/agentSystemPrompt.ts b/src/agentMode/backends/shared/agentSystemPrompt.ts index efb8bcc9..06a2295a 100644 --- a/src/agentMode/backends/shared/agentSystemPrompt.ts +++ b/src/agentMode/backends/shared/agentSystemPrompt.ts @@ -153,8 +153,8 @@ export function buildAgentSystemPrompt(opts?: { projectInstructions?: string }): if (!getDisableBuiltinSystemPrompt()) { parts.push(COPILOT_PROMPT_BASE); // Always steer toward the builtin Copilot Plus skills, regardless of Plus - // status. Gating on `isPlusUser` would be wrong anyway — valid self-host - // mode is Plus-enabled but reports `isPlusUser: false` — and if a skill + // status. Gating on `isPaidUser` would be wrong anyway — valid self-host + // mode is Plus-enabled but reports `isPaidUser: false` — and if a skill // can't run, its script exits telling the agent to use its own equivalent // tools and the fallback clause routes it there. Never blocks free users. parts.push(COPILOT_PLUS_TOOLS_STEERING); diff --git a/src/agentMode/backends/shared/copilotPlusEnv.test.ts b/src/agentMode/backends/shared/copilotPlusEnv.test.ts index 15435c36..52f13459 100644 --- a/src/agentMode/backends/shared/copilotPlusEnv.test.ts +++ b/src/agentMode/backends/shared/copilotPlusEnv.test.ts @@ -25,7 +25,7 @@ beforeEach(() => { describe("buildCopilotPlusEnv", () => { it("returns the decrypted license + relay config for an active Plus user", async () => { mockGetSettings.mockReturnValue({ - isPlusUser: true, + isPaidUser: true, plusLicenseKey: "encrypted-key", userId: "user-123", }); @@ -42,31 +42,31 @@ describe("buildCopilotPlusEnv", () => { }); it("returns empty when the user is not a Plus subscriber", async () => { - mockGetSettings.mockReturnValue({ isPlusUser: false, plusLicenseKey: "encrypted-key" }); + mockGetSettings.mockReturnValue({ isPaidUser: false, plusLicenseKey: "encrypted-key" }); expect(await buildCopilotPlusEnv()).toEqual({}); expect(mockGetDecryptedKey).not.toHaveBeenCalled(); }); it("returns empty when there is no license key on file", async () => { - mockGetSettings.mockReturnValue({ isPlusUser: true, plusLicenseKey: "" }); + mockGetSettings.mockReturnValue({ isPaidUser: true, plusLicenseKey: "" }); expect(await buildCopilotPlusEnv()).toEqual({}); expect(mockGetDecryptedKey).not.toHaveBeenCalled(); }); it("returns empty (not a throw) when decryption fails", async () => { - mockGetSettings.mockReturnValue({ isPlusUser: true, plusLicenseKey: "encrypted-key" }); + mockGetSettings.mockReturnValue({ isPaidUser: true, plusLicenseKey: "encrypted-key" }); mockGetDecryptedKey.mockRejectedValue(new Error("bad key")); expect(await buildCopilotPlusEnv()).toEqual({}); }); it("returns empty when the decrypted key is blank", async () => { - mockGetSettings.mockReturnValue({ isPlusUser: true, plusLicenseKey: "encrypted-key" }); + mockGetSettings.mockReturnValue({ isPaidUser: true, plusLicenseKey: "encrypted-key" }); mockGetDecryptedKey.mockResolvedValue(""); expect(await buildCopilotPlusEnv()).toEqual({}); }); it("injects MIYO_URL when a custom Miyo server URL is set, independent of Plus", async () => { - mockGetSettings.mockReturnValue({ isPlusUser: false }); + mockGetSettings.mockReturnValue({ isPaidUser: false }); mockGetMiyoCustomUrl.mockReturnValue("http://192.168.1.10:8742"); expect(await buildCopilotPlusEnv()).toEqual({ MIYO_URL: "http://192.168.1.10:8742" }); // Non-Plus: no relay env, and no decryption attempted. @@ -75,7 +75,7 @@ describe("buildCopilotPlusEnv", () => { it("merges MIYO_URL with the Plus relay env for a Plus user with a custom Miyo URL", async () => { mockGetSettings.mockReturnValue({ - isPlusUser: true, + isPaidUser: true, plusLicenseKey: "encrypted-key", userId: "user-123", }); diff --git a/src/agentMode/backends/shared/copilotPlusEnv.ts b/src/agentMode/backends/shared/copilotPlusEnv.ts index 09959848..a600b7ce 100644 --- a/src/agentMode/backends/shared/copilotPlusEnv.ts +++ b/src/agentMode/backends/shared/copilotPlusEnv.ts @@ -38,7 +38,7 @@ export async function buildCopilotPlusEnv( if (miyoUrl) env[MIYO_URL_ENV] = miyoUrl; // Copilot Plus relay env — gated on an active subscription with a usable key. - if (settings.isPlusUser && settings.plusLicenseKey) { + if (settings.isPaidUser && settings.plusLicenseKey) { try { const licenseKey = await getDecryptedKey(settings.plusLicenseKey); if (licenseKey) { diff --git a/src/agentMode/index.ts b/src/agentMode/index.ts index a9818df2..5142e566 100644 --- a/src/agentMode/index.ts +++ b/src/agentMode/index.ts @@ -330,7 +330,7 @@ export function createAgentSessionManager(app: App, plugin: CopilotPlugin): Agen // stale license/URL until a reload. (Restarts coalesce, so this folds with // any Miyo-availability re-seed restart below.) if ( - prev.isPlusUser !== next.isPlusUser || + prev.isPaidUser !== next.isPaidUser || prev.plusLicenseKey !== next.plusLicenseKey || prev.miyoServerUrl !== next.miyoServerUrl ) { @@ -355,7 +355,7 @@ export function createAgentSessionManager(app: App, plugin: CopilotPlugin): Agen const miyoAvailabilityChanged = prev.enableMiyo !== next.enableMiyo || prev.miyoServerUrl !== next.miyoServerUrl || - prev.isPlusUser !== next.isPlusUser || + prev.isPaidUser !== next.isPaidUser || prev.selfHostModeValidatedAt !== next.selfHostModeValidatedAt || prev.selfHostValidationCount !== next.selfHostValidationCount; if (prevFolder !== nextFolder || miyoAvailabilityChanged) { diff --git a/src/agentMode/session/AgentSession.test.ts b/src/agentMode/session/AgentSession.test.ts index 5de7d5d8..9c24805a 100644 --- a/src/agentMode/session/AgentSession.test.ts +++ b/src/agentMode/session/AgentSession.test.ts @@ -1078,6 +1078,10 @@ describe("ensureMultiAgentEntitlement (paywall helper)", () => { // These exercise the REAL helper against mocked isPlusEnabled/BrevilabsClient, // verifying the fast path takes no network call and the slow path re-verifies. const validateLicenseKey = jest.fn(); + // Mutable so the validateLicenseKey mock can simulate the real side effect of + // applying the entitlement (flipping the cached flags) that the slow path then + // re-reads via isPlusEnabled(). + let settings: Record; beforeEach(() => { jest.resetModules(); @@ -1087,6 +1091,7 @@ describe("ensureMultiAgentEntitlement (paywall helper)", () => { async function loadHelper( isPlus: boolean ): Promise<(app?: unknown, ctx?: Record) => Promise> { + settings = { isPlusUser: isPlus, isPaidUser: isPlus, enableSelfHostMode: false }; jest.doMock("@/plusUtils", () => jest.requireActual("@/plusUtils")); jest.doMock("@/logger", () => ({ logInfo: jest.fn(), @@ -1094,8 +1099,8 @@ describe("ensureMultiAgentEntitlement (paywall helper)", () => { logError: jest.fn(), })); jest.doMock("@/settings/model", () => ({ - getSettings: jest.fn().mockReturnValue({ isPlusUser: isPlus, enableSelfHostMode: false }), - setSettings: jest.fn(), + getSettings: jest.fn(() => settings), + setSettings: jest.fn((partial: Record) => Object.assign(settings, partial)), updateSetting: jest.fn(), useSettingsValue: jest.fn(), })); @@ -1112,8 +1117,13 @@ describe("ensureMultiAgentEntitlement (paywall helper)", () => { expect(validateLicenseKey).not.toHaveBeenCalled(); }); - it("slow path: a stale-false cache that the backend confirms paid is allowed", async () => { - validateLicenseKey.mockResolvedValue({ isValid: true }); + it("slow path: a stale-false cache the backend confirms as Plus is allowed", async () => { + // The real validateLicenseKey applies the entitlement; simulate that. + validateLicenseKey.mockImplementation(async () => { + settings.isPaidUser = true; + settings.isPlusUser = true; + return { isValid: true }; + }); const ensure = await loadHelper(false); await expect(ensure()).resolves.toBe(true); expect(validateLicenseKey).toHaveBeenCalledTimes(1); @@ -1121,6 +1131,18 @@ describe("ensureMultiAgentEntitlement (paywall helper)", () => { expect(validateLicenseKey.mock.calls[0][1]).toMatchObject({ feature: "multi_agent_per_turn" }); }); + it("slow path: a Lite user (paid but below Plus) is blocked", async () => { + // Backend confirms a paid license, but the entitlement is below Plus — the + // gate keys on Plus tier, not on isValid. + validateLicenseKey.mockImplementation(async () => { + settings.isPaidUser = true; + settings.isPlusUser = false; + return { isValid: true }; + }); + const ensure = await loadHelper(false); + await expect(ensure()).resolves.toBe(false); + }); + it("slow path: a genuinely free user is blocked (isValid false)", async () => { validateLicenseKey.mockResolvedValue({ isValid: false }); const ensure = await loadHelper(false); diff --git a/src/commands/index.ts b/src/commands/index.ts index 1b96e759..71177308 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -20,7 +20,7 @@ import { CustomCommandChatModal } from "@/commands/CustomCommandChatModal"; import { ConfirmModal } from "@/components/modals/ConfirmModal"; import { ApplyCustomCommandModal } from "@/components/modals/ApplyCustomCommandModal"; import { YoutubeTranscriptModal } from "@/components/modals/YoutubeTranscriptModal"; -import { checkIsPlusUser } from "@/plusUtils"; +import { checkIsPaidUser } from "@/plusUtils"; // Debug modals removed with search v3 import CopilotPlugin from "@/main"; import { shouldUseMiyo } from "@/miyo/miyoUtils"; @@ -658,8 +658,8 @@ export function registerCommands(plugin: CopilotPlugin) { // Add command to download YouTube script (Copilot Plus only) addCommand(plugin, COMMAND_IDS.DOWNLOAD_YOUTUBE_SCRIPT, async () => { - const isPlusUser = await checkIsPlusUser(plugin.app); - if (!isPlusUser) { + const isPaidUser = await checkIsPaidUser(plugin.app); + if (!isPaidUser) { new Notice("Download YouTube Script (plus) is a Copilot Plus feature"); return; } diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index e7b1acc7..d2703e1b 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -42,7 +42,7 @@ import ChainManager from "@/LLMProviders/chainManager"; import { clearRecordedPromptPayload } from "@/LLMProviders/chainRunner/utils/promptPayloadRecorder"; import { logFileManager } from "@/logFileManager"; import CopilotPlugin from "@/main"; -import { useIsPlusUser } from "@/plusUtils"; +import { useIsPaidUser } from "@/plusUtils"; import { ProjectFileManager } from "@/projects/ProjectFileManager"; import { useProjects } from "@/projects/state"; import { getModelKeyFromModel, useSettingsValue } from "@/settings/model"; @@ -265,7 +265,7 @@ const ChatInternal: React.FC(null); const [selectedChain, setSelectedChain] = useChainType(); - const isPlusUser = useIsPlusUser(); + const isPaidUser = useIsPaidUser(); const appContext = useContext(AppContext); const app = plugin.app || appContext; @@ -1015,7 +1015,7 @@ const ChatInternal: React.FC { // If leaving project mode with autosave enabled, save chat BEFORE clearing project context @@ -282,7 +282,7 @@ export function ChatControls({ > vault QA (free) - {isPlusUser ? ( + {isPaidUser ? ( { void handleModeChange(ChainType.COPILOT_PLUS_CHAIN); @@ -305,7 +305,7 @@ export function ChatControls({ )} - {isPlusUser ? ( + {isPaidUser ? ( { diff --git a/src/constants.ts b/src/constants.ts index 89f392d2..2e21703a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -951,7 +951,10 @@ export const OPENCODE_RELEASE_API_URL_TEMPLATE = export const DEFAULT_SETTINGS: CopilotSettings = { userId: uuidv4(), + isPaidUser: false, isPlusUser: false, + entitlementToken: "", + entitlementExpiresAt: 0, plusLicenseKey: "", openAIApiKey: "", openAIOrgId: "", diff --git a/src/entitlement/index.ts b/src/entitlement/index.ts new file mode 100644 index 00000000..908fc4f3 --- /dev/null +++ b/src/entitlement/index.ts @@ -0,0 +1,4 @@ +export { ENTITLEMENT_PUBLIC_KEYS } from "./publicKeys"; +export type { EntitlementClaims, EntitlementFeature, EntitlementTier } from "./types"; +export { verifyEntitlement } from "./verify"; +export type { VerifyEntitlementOptions } from "./verify"; diff --git a/src/entitlement/publicKeys.ts b/src/entitlement/publicKeys.ts new file mode 100644 index 00000000..a0d45127 --- /dev/null +++ b/src/entitlement/publicKeys.ts @@ -0,0 +1,19 @@ +/** + * Public keys that verify entitlement tokens, keyed by the token header's `kid` + * so a new signing key can roll out before old tokens expire. These are PUBLIC + * (verify-only) — safe to ship in open-source client code; they grant no power + * to mint tokens. The matching private key signs tokens in `brevilabs-api`. + * + * To rotate: generate a new ES256 (P-256) pair, add the public JWK under a new + * `kid` here while keeping the old one until its tokens expire, then point the + * server at the new private key. See the "Copilot Entitlement Token Design" doc + * and obsidian-copilot-preview#201. + */ +export const ENTITLEMENT_PUBLIC_KEYS: Record = { + "ent-2026-06": { + kty: "EC", + crv: "P-256", + x: "9j3HVz0TWJ5VFMTiaNhExKQJAPWjduz2wdZpKAXIkzk", + y: "ZA9ARslf7l1XUK5zL5Tgbm-eY-zxWaVLzQD7nTnId6Y", + }, +}; diff --git a/src/entitlement/types.ts b/src/entitlement/types.ts new file mode 100644 index 00000000..49f829fe --- /dev/null +++ b/src/entitlement/types.ts @@ -0,0 +1,25 @@ +/** Normalized subscription tier rank, ascending. */ +export type EntitlementTier = "free" | "lite" | "plus" | "pro"; + +/** Server-granted capability flags. The server owns the plan→features policy. */ +export type EntitlementFeature = "multi_agent" | "self_host"; + +/** + * Claims carried by a server-signed entitlement token (JWS payload). The server + * is the single source of truth for `tier` and `features`; the client only reads + * them, never maps plan names. See the "Copilot Entitlement Token Design" doc. + */ +export interface EntitlementClaims { + /** Account the token is bound to; must match the local `userId`. */ + user_id: string; + /** Raw plan name, for display/telemetry only — never gated on. */ + plan: string; + /** Normalized tier rank. */ + tier: EntitlementTier; + /** Granted capabilities. */ + features: EntitlementFeature[]; + /** Issued-at, epoch seconds. */ + iat: number; + /** Expiry, epoch seconds — the offline trust window. */ + exp: number; +} diff --git a/src/entitlement/verify.test.ts b/src/entitlement/verify.test.ts new file mode 100644 index 00000000..431995f4 --- /dev/null +++ b/src/entitlement/verify.test.ts @@ -0,0 +1,136 @@ +// Uses Node's WebCrypto (imported explicitly, not the global) for ECDSA P-256 key +// generation/signing, and injects it into verifyEntitlement via the `subtle` +// option. This keeps the test independent of the environment's global WebCrypto — +// jsdom ships only a partial SubtleCrypto (no generateKey/ECDSA), and patching it +// proved unreliable across CI. The verification logic is WebCrypto-spec behavior, +// identical between Node and the Obsidian webview. +import { webcrypto } from "crypto"; + +import type { EntitlementClaims } from "./types"; +import { verifyEntitlement, type VerifyEntitlementOptions } from "./verify"; + +// Node's webcrypto.SubtleCrypto and the DOM SubtleCrypto differ only in unrelated +// overloads (e.g. Ed25519); cast to the DOM type the API expects. +const subtle = webcrypto.subtle as unknown as SubtleCrypto; +const KID = "test-key"; +const USER_ID = "user-123"; + +/** verifyEntitlement with Node's subtle injected; tests pass the rest of opts. */ +function verify(token: string, opts: Omit = {}) { + return verifyEntitlement(token, { subtle, ...opts }); +} + +function base64UrlEncode(input: string | Uint8Array): string { + const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input; + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +async function signToken( + privateKey: CryptoKey, + claims: Record, + header: Record = { alg: "ES256", typ: "JWT", kid: KID } +): Promise { + const headerSegment = base64UrlEncode(JSON.stringify(header)); + const payloadSegment = base64UrlEncode(JSON.stringify(claims)); + const signature = await subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + privateKey, + new TextEncoder().encode(`${headerSegment}.${payloadSegment}`) + ); + return `${headerSegment}.${payloadSegment}.${base64UrlEncode(new Uint8Array(signature))}`; +} + +// Far-future expiry (epoch seconds) so tokens are valid unless a test overrides it. +const FUTURE_EXP = Math.floor(Date.UTC(2099, 0, 1) / 1000); + +function plusClaims(overrides: Partial = {}): Record { + return { + user_id: USER_ID, + plan: "plus", + tier: "plus", + features: ["multi_agent", "self_host"], + iat: 0, + exp: FUTURE_EXP, + ...overrides, + }; +} + +describe("verifyEntitlement", () => { + let keyPair: CryptoKeyPair; + let publicKeys: Record; + + beforeAll(async () => { + keyPair = await subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + "verify", + ]); + publicKeys = { [KID]: await subtle.exportKey("jwk", keyPair.publicKey) }; + }); + + it("returns claims for a valid, correctly-signed token", async () => { + const token = await signToken(keyPair.privateKey, plusClaims()); + const claims = await verify(token, { publicKeys, expectedUserId: USER_ID }); + expect(claims).not.toBeNull(); + expect(claims?.tier).toBe("plus"); + expect(claims?.features).toContain("multi_agent"); + }); + + it("returns Lite claims without the multi_agent feature", async () => { + const token = await signToken( + keyPair.privateKey, + plusClaims({ plan: "lite", tier: "lite", features: [] }) + ); + const claims = await verify(token, { publicKeys }); + expect(claims?.tier).toBe("lite"); + expect(claims?.features).not.toContain("multi_agent"); + }); + + it("rejects an expired token", async () => { + const token = await signToken( + keyPair.privateKey, + plusClaims({ exp: Math.floor(Date.UTC(2020, 0, 1) / 1000) }) + ); + expect(await verify(token, { publicKeys })).toBeNull(); + }); + + it("rejects a token whose user_id does not match the local user", async () => { + const token = await signToken(keyPair.privateKey, plusClaims()); + expect(await verify(token, { publicKeys, expectedUserId: "someone-else" })).toBeNull(); + }); + + it("rejects a token signed by an unknown key (kid not in the trust set)", async () => { + const otherPair = await subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + "verify", + ]); + const token = await signToken(otherPair.privateKey, plusClaims()); + expect(await verify(token, { publicKeys })).toBeNull(); + }); + + it("rejects a token whose payload was tampered with after signing", async () => { + const token = await signToken(keyPair.privateKey, plusClaims({ tier: "lite", features: [] })); + const [header, , signature] = token.split("."); + const forgedPayload = base64UrlEncode( + JSON.stringify(plusClaims({ tier: "pro", features: ["multi_agent"] })) + ); + const forged = `${header}.${forgedPayload}.${signature}`; + expect(await verify(forged, { publicKeys })).toBeNull(); + }); + + it("rejects a non-ES256 algorithm", async () => { + const token = await signToken(keyPair.privateKey, plusClaims(), { + alg: "none", + typ: "JWT", + kid: KID, + }); + expect(await verify(token, { publicKeys })).toBeNull(); + }); + + it("rejects malformed tokens and the empty string", async () => { + expect(await verify("", { publicKeys })).toBeNull(); + expect(await verify("not-a-jwt", { publicKeys })).toBeNull(); + expect(await verify("only.two", { publicKeys })).toBeNull(); + }); +}); diff --git a/src/entitlement/verify.ts b/src/entitlement/verify.ts new file mode 100644 index 00000000..f7e8d841 --- /dev/null +++ b/src/entitlement/verify.ts @@ -0,0 +1,104 @@ +import { ENTITLEMENT_PUBLIC_KEYS } from "./publicKeys"; +import type { EntitlementClaims } from "./types"; + +export interface VerifyEntitlementOptions { + /** Current time in epoch ms; defaults to `Date.now()`. Injected by tests. */ + now?: number; + /** Public keys keyed by `kid`; defaults to the embedded set. Injected by tests. */ + publicKeys?: Record; + /** When set, the token's `user_id` must equal this or verification fails. */ + expectedUserId?: string; + /** + * SubtleCrypto implementation; defaults to the runtime's `crypto.subtle` + * (present in Obsidian desktop/Electron and mobile WebViews). Injected by tests + * so they don't depend on the environment's global WebCrypto. + */ + subtle?: SubtleCrypto; +} + +interface JwsHeader { + alg?: string; + kid?: string; +} + +/** Decode a base64url segment to bytes. */ +function base64UrlToBytes(segment: string): Uint8Array { + const normalized = segment.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function parseJsonSegment(segment: string): T | null { + try { + return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment))) as T; + } catch { + return null; + } +} + +/** + * Verify a server-signed entitlement token offline and return its claims, or + * `null` if the token is malformed, signed by an unknown key, has a bad + * signature, is expired, or is bound to a different user. + * + * ES256 (ECDSA P-256 + SHA-256). The embedded public key can only verify, so + * this is robust against forged entitlement data (edited `data.json`, faked + * `/license` responses) even though the client code is public — the only + * remaining bypass is recompiling the plugin. See the "Copilot Entitlement Token + * Design" doc. + */ +export async function verifyEntitlement( + token: string, + options: VerifyEntitlementOptions = {} +): Promise { + const { + now = Date.now(), + publicKeys = ENTITLEMENT_PUBLIC_KEYS, + expectedUserId, + subtle = crypto.subtle, + } = options; + if (!token) return null; + + const parts = token.split("."); + if (parts.length !== 3) return null; + const [headerSegment, payloadSegment, signatureSegment] = parts; + + const header = parseJsonSegment(headerSegment); + if (!header || header.alg !== "ES256" || !header.kid) return null; + + const jwk = publicKeys[header.kid]; + if (!jwk) return null; + + let verified = false; + try { + const key = await subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, false, [ + "verify", + ]); + verified = await subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + key, + base64UrlToBytes(signatureSegment), + new TextEncoder().encode(`${headerSegment}.${payloadSegment}`) + ); + } catch { + // Any verification error (malformed JWK, unsupported curve, bad signature + // bytes) is treated as a non-verifying token; the caller decides the fallback. + return null; + } + if (!verified) return null; + + const claims = parseJsonSegment(payloadSegment); + if (!claims || typeof claims.user_id !== "string" || !Array.isArray(claims.features)) { + return null; + } + // `exp` is epoch seconds (JWT convention); compare against epoch ms. + if (typeof claims.exp !== "number" || claims.exp * 1000 <= now) return null; + if (expectedUserId && claims.user_id !== expectedUserId) return null; + + return claims; +} diff --git a/src/main.ts b/src/main.ts index c7780baf..84d17a69 100644 --- a/src/main.ts +++ b/src/main.ts @@ -52,7 +52,11 @@ import { } from "@/services/settingsPersistence"; import { UserMemoryManager } from "@/memory/UserMemoryManager"; import { clearRecordedPromptPayload } from "@/LLMProviders/chainRunner/utils/promptPayloadRecorder"; -import { checkIsPlusUser, refreshSelfHostModeValidation } from "@/plusUtils"; +import { + checkIsPaidUser, + refreshSelfHostModeValidation, + verifyCachedEntitlement, +} from "@/plusUtils"; import { getWebViewerService, startActiveWebTabTracking, @@ -177,14 +181,14 @@ export default class CopilotPlugin extends Plugin { // sign-out→sign-in (each its own settings change) settles in issue order, // not in whichever overlapping reconcile happens to finish last. let plusSyncChain: Promise = Promise.resolve(); - const syncPlus = (isPlusUser: boolean | undefined, licenseKey: string): void => { + const syncPlus = (isPaidUser: boolean | undefined, licenseKey: string): void => { plusSyncChain = plusSyncChain.then(() => - syncCopilotPlusProvider(this.modelManagement, !!isPlusUser, licenseKey) + syncCopilotPlusProvider(this.modelManagement, !!isPaidUser, licenseKey) ); }; - // Initial reconcile: an already-signed-in user's `isPlusUser` is restored + // Initial reconcile: an already-signed-in user's `isPaidUser` is restored // from disk without firing the subscription, so register on load. - syncPlus(getSettings().isPlusUser, getSettings().plusLicenseKey); + syncPlus(getSettings().isPaidUser, getSettings().plusLicenseKey); this.settingsUnsubscriber = subscribeToSettingsChange((prev, next) => { void (async () => { try { @@ -199,19 +203,12 @@ export default class CopilotPlugin extends Plugin { logError("Failed to persist settings.", error); new Notice("Copilot failed to save settings. Check logs and try again."); } - // Sign-in / sign-out (isPlusUser flip) or key rotation while signed in. + // Sign-in / sign-out (isPaidUser flip) or key rotation while signed in. if ( - prev?.isPlusUser !== next.isPlusUser || - (next.isPlusUser && prev?.plusLicenseKey !== next.plusLicenseKey) + prev?.isPaidUser !== next.isPaidUser || + (next.isPaidUser && prev?.plusLicenseKey !== next.plusLicenseKey) ) { - syncPlus(next.isPlusUser, next.plusLicenseKey); - } - // Sign-in / sign-out (isPlusUser flip) or key rotation while signed in. - if ( - prev?.isPlusUser !== next.isPlusUser || - (next.isPlusUser && prev?.plusLicenseKey !== next.plusLicenseKey) - ) { - syncPlus(next.isPlusUser, next.plusLicenseKey); + syncPlus(next.isPaidUser, next.plusLicenseKey); } })(); }); @@ -238,7 +235,11 @@ export default class CopilotPlugin extends Plugin { // Initialize BrevilabsClient this.brevilabsClient = BrevilabsClient.getInstance(); this.brevilabsClient.setPluginVersion(this.manifest.version); - void checkIsPlusUser(this.app); + // Re-verify the cached entitlement token offline so the strict Plus gate + // fails closed against an edited data.json until the signature re-proves + // itself. The network re-validation below overrides with the server's token. + void verifyCachedEntitlement(); + void checkIsPaidUser(this.app); void refreshSelfHostModeValidation(); // Initialize ProjectManager diff --git a/src/modelManagement/setup/copilotPlusSync.test.ts b/src/modelManagement/setup/copilotPlusSync.test.ts index 37a19f2b..81b76932 100644 --- a/src/modelManagement/setup/copilotPlusSync.test.ts +++ b/src/modelManagement/setup/copilotPlusSync.test.ts @@ -2,7 +2,7 @@ * Tests for `syncCopilotPlusProvider` — the sign-in/sign-out bridge that * reconciles the singleton Copilot Plus provider. * - * The register/unregister decision must key on Plus sign-in state (`isPlusUser` + * The register/unregister decision must key on paid sign-in state (`isPaidUser` * + a raw stored key), NOT on whether that key decrypts. A decrypt failure * (safeStorage unavailable, vault synced to another machine) returns "" from * `getDecryptedKey`; treating that as sign-out would tear down the persisted diff --git a/src/modelManagement/setup/copilotPlusSync.ts b/src/modelManagement/setup/copilotPlusSync.ts index d3fd69ec..b593466f 100644 --- a/src/modelManagement/setup/copilotPlusSync.ts +++ b/src/modelManagement/setup/copilotPlusSync.ts @@ -107,7 +107,7 @@ export const COPILOT_PLUS_DEFAULT_ENABLED_MODELS: readonly string[] = Object.fre * * `licenseKey` is the RAW stored key (still encrypted on disk) — the same value * the rest of the plugin gates on (`brevilabsClient`, `plusUtils`). The - * register/unregister decision keys on sign-in state (`isPlusUser` + a stored + * register/unregister decision keys on sign-in state (`isPaidUser` + a stored * key), NOT on whether that key happens to decrypt: a decrypt failure (Electron * `safeStorage` unavailable, a vault synced to another machine) must not tear * down the persisted provider + the user's curation. Decryption is only for the @@ -116,11 +116,11 @@ export const COPILOT_PLUS_DEFAULT_ENABLED_MODELS: readonly string[] = Object.fre */ export async function syncCopilotPlusProvider( api: ModelManagementApi, - isPlusUser: boolean, + isPaidUser: boolean, licenseKey: string | undefined ): Promise { try { - if (isPlusUser && licenseKey) { + if (isPaidUser && licenseKey) { const token = await getDecryptedKey(licenseKey); await api.setup.copilotPlus.registerPlusProvider({ providerType: "openai-compatible", diff --git a/src/plusUtils.test.ts b/src/plusUtils.test.ts index 7e68b7cf..d5c13edf 100644 --- a/src/plusUtils.test.ts +++ b/src/plusUtils.test.ts @@ -2,12 +2,26 @@ import { DEFAULT_SETTINGS } from "@/constants"; import type { CopilotSettings } from "@/settings/model"; const mockGetSettings = jest.fn(); +const mockSetSettings = jest.fn]>(); jest.mock("@/settings/model", () => ({ getSettings: () => mockGetSettings(), + setSettings: (partial: Partial) => mockSetSettings(partial), })); -import { canUseMultiAgent, isSelfHostAccessValid, isSelfHostModeValid } from "@/plusUtils"; +const mockVerifyEntitlement = jest.fn, [string, unknown?]>(); + +jest.mock("@/entitlement", () => ({ + verifyEntitlement: (...args: [string, unknown?]) => mockVerifyEntitlement(...args), +})); + +import { + applyEntitlement, + canUseMultiAgent, + isSelfHostAccessValid, + isSelfHostModeValid, + verifyCachedEntitlement, +} from "@/plusUtils"; const SELF_HOST_GRACE_PERIOD_MS = 15 * 24 * 60 * 60 * 1000; @@ -52,6 +66,14 @@ describe("isSelfHostAccessValid", () => { }); describe("canUseMultiAgent", () => { + // Reset the in-memory "verified this session" proof before each case so the + // strict gate's token-derived branch starts from a fail-closed state. + beforeEach(async () => { + mockVerifyEntitlement.mockReset(); + mockGetSettings.mockReturnValue(buildSettings({ entitlementToken: "" })); + await verifyCachedEntitlement(); + }); + it("returns false for a free user (no Plus, no self-host)", () => { mockGetSettings.mockReturnValue( buildSettings({ isPlusUser: false, enableSelfHostMode: false }) @@ -64,10 +86,142 @@ describe("canUseMultiAgent", () => { expect(canUseMultiAgent()).toBe(true); }); + it("returns false for a Lite user (paid but below Plus)", () => { + mockGetSettings.mockReturnValue( + buildSettings({ isPaidUser: true, isPlusUser: false, enableSelfHostMode: false }) + ); + expect(canUseMultiAgent()).toBe(false); + }); + it("returns true when self-host mode is on (believer/supporter offline path)", () => { mockGetSettings.mockReturnValue(buildSettings({ isPlusUser: false, enableSelfHostMode: true })); expect(canUseMultiAgent()).toBe(true); }); + + it("returns false when the entitlement token has expired (offline lock)", () => { + mockGetSettings.mockReturnValue( + buildSettings({ + isPlusUser: true, + enableSelfHostMode: false, + entitlementExpiresAt: Date.now() - 1000, + }) + ); + expect(canUseMultiAgent()).toBe(false); + }); + + it("blocks token-derived Plus that was not verified this session (edited data.json)", () => { + // A persisted isPlusUser=true plus a future expiry, with no signature + // verified this process — the data.json-tampering case. Fails closed. + mockGetSettings.mockReturnValue( + buildSettings({ + isPlusUser: true, + enableSelfHostMode: false, + entitlementToken: "forged-or-stale", + entitlementExpiresAt: Date.now() + 60_000, + }) + ); + expect(canUseMultiAgent()).toBe(false); + }); + + it("allows token-derived Plus once the signed token is verified this session", async () => { + // Re-verifying the cached token (offline) sets the in-memory proof, so the + // strict gate trusts the unexpired entitlement. + mockVerifyEntitlement.mockResolvedValue({ + user_id: "user-123", + plan: "plus", + tier: "plus", + features: ["multi_agent"], + iat: 0, + exp: 9_999_999_999, + }); + mockGetSettings.mockReturnValue( + buildSettings({ userId: "user-123", entitlementToken: "token" }) + ); + await verifyCachedEntitlement(); + + mockGetSettings.mockReturnValue( + buildSettings({ + isPlusUser: true, + enableSelfHostMode: false, + entitlementToken: "token", + entitlementExpiresAt: Date.now() + 60_000, + }) + ); + expect(canUseMultiAgent()).toBe(true); + }); +}); + +describe("applyEntitlement", () => { + beforeEach(() => { + mockSetSettings.mockClear(); + mockVerifyEntitlement.mockReset(); + mockGetSettings.mockReturnValue(buildSettings({ userId: "user-123" })); + }); + + it("grants Plus for a token carrying the multi_agent feature", async () => { + mockVerifyEntitlement.mockResolvedValue({ + user_id: "user-123", + plan: "plus", + tier: "plus", + features: ["multi_agent", "self_host"], + iat: 0, + exp: 9_999_999_999, + }); + expect(await applyEntitlement("token")).toBe(true); + expect(mockSetSettings).toHaveBeenCalledWith({ + entitlementToken: "token", + entitlementExpiresAt: 9_999_999_999_000, + isPaidUser: true, + isPlusUser: true, + }); + }); + + it("applies a Lite token as paid but not Plus", async () => { + mockVerifyEntitlement.mockResolvedValue({ + user_id: "user-123", + plan: "lite", + tier: "lite", + features: [], + iat: 0, + exp: 9_999_999_999, + }); + // Returns true (verified + applied); the tier shows in the flags, not the + // return value. + expect(await applyEntitlement("token")).toBe(true); + expect(mockSetSettings).toHaveBeenCalledWith({ + entitlementToken: "token", + entitlementExpiresAt: 9_999_999_999_000, + isPaidUser: true, + isPlusUser: false, + }); + }); + + it("grants Plus for a Pro token", async () => { + mockVerifyEntitlement.mockResolvedValue({ + user_id: "user-123", + plan: "pro", + tier: "pro", + features: ["multi_agent"], + iat: 0, + exp: 9_999_999_999, + }); + expect(await applyEntitlement("token")).toBe(true); + expect(mockSetSettings).toHaveBeenCalledWith({ + entitlementToken: "token", + entitlementExpiresAt: 9_999_999_999_000, + isPaidUser: true, + isPlusUser: true, + }); + }); + + it("does NOT change settings when the token cannot be verified", async () => { + // An unverifiable token (bad signature, expired, unknown kid, or empty key + // set during rollout) is not an authoritative negative, so flags are left + // untouched for the caller to decide the fallback. Only turnOffPaid clears. + mockVerifyEntitlement.mockResolvedValue(null); + expect(await applyEntitlement("bad")).toBe(false); + expect(mockSetSettings).not.toHaveBeenCalled(); + }); }); describe("isSelfHostModeValid", () => { diff --git a/src/plusUtils.ts b/src/plusUtils.ts index 17f5869c..6be8905d 100644 --- a/src/plusUtils.ts +++ b/src/plusUtils.ts @@ -9,9 +9,16 @@ import { PLUS_UTM_MEDIUMS, PlusUtmMedium, } from "@/constants"; +import { verifyEntitlement } from "@/entitlement"; import { BrevilabsClient } from "@/LLMProviders/brevilabsClient"; import { logError, logInfo } from "@/logger"; -import { getSettings, setSettings, updateSetting, useSettingsValue } from "@/settings/model"; +import { + CopilotSettings, + getSettings, + setSettings, + updateSetting, + useSettingsValue, +} from "@/settings/model"; import { App, Notice } from "obsidian"; import React from "react"; @@ -94,47 +101,110 @@ export function isPlusModel(modelKey: string): boolean { } /** - * Synchronous check if Plus features should be enabled. - * Returns true when self-host mode is valid OR user has valid Plus subscription. - * Use this for synchronous checks (e.g., model validation, UI state). + * Synchronous check for paid (any valid license, incl. Lite) feature access. + * Returns true when self-host mode is valid OR the user has any valid paid + * subscription. Use this for the broad Plus-feature gates (model validation, UI + * state) that should remain available to every paying user. */ -export function isPlusEnabled(): boolean { +export function isPaidEnabled(): boolean { const settings = getSettings(); - // Self-host mode with valid plan validation bypasses Plus requirements + // Self-host mode with valid plan validation bypasses subscription requirements if (isSelfHostModeValid()) { return true; } + return settings.isPaidUser === true; +} + +/** + * True once the entitlement token's expiry has passed. Only token-derived state + * carries an expiry (tokenless fallback leaves it 0), so this honors the JWS + * `exp` for the strict gate even offline, without affecting the broad paid gate. + */ +function isEntitlementExpired(settings: CopilotSettings): boolean { + return settings.entitlementExpiresAt > 0 && Date.now() >= settings.entitlementExpiresAt; +} + +/** + * In-memory (never persisted) proof that a signed entitlement granting the + * `multi_agent` feature was cryptographically verified in THIS process — set by + * {@link applyEntitlement} (server response) or {@link verifyCachedEntitlement} + * (cached token re-checked at startup). The strict Plus gate requires it for + * token-derived state, so an edited `data.json` (which can flip persisted + * booleans and the expiry, but cannot forge an ES256 signature) fails closed. + */ +let strictEntitlementVerified = false; + +/** + * Synchronous check for tier >= Plus (excludes Lite) — the gate for + * Plus-and-above features such as multi-agent fan-out. Self-host plans + * (Believer/Supporter) are >= Plus, so a valid self-host bypass grants this too. + */ +export function isPlusEnabled(): boolean { + const settings = getSettings(); + if (isSelfHostModeValid()) { + return true; + } + // Token-derived Plus carries an expiry. Trust it only when the signed token + // was cryptographically verified this session (not merely a persisted + // `isPlusUser` boolean) and the `exp` has not passed — so editing data.json + // cannot unlock the strict gate, even offline. + if (settings.entitlementExpiresAt > 0) { + return strictEntitlementVerified && !isEntitlementExpired(settings); + } + // Tokenless fallback (server has not issued a signed entitlement yet): no + // artifact exists to verify, so honor the license-confirmed flag as before. return settings.isPlusUser === true; } /** - * Hook to get the isPlusUser setting. - * Returns true when self-host mode is valid to allow offline usage. + * Self-host bypass for the reactive hooks: a license-keyed self-host user with a + * still-valid validation receipt (permanent or within grace) is treated as + * entitled offline. Mirrors the offline allowance in `useIsSelfHostEligible`. + */ +function hasSelfHostHookBypass(settings: CopilotSettings): boolean { + if ( + !settings.plusLicenseKey || + !settings.enableSelfHostMode || + settings.selfHostModeValidatedAt == null + ) { + return false; + } + if (settings.selfHostValidationCount >= SELF_HOST_PERMANENT_VALIDATION_COUNT) { + return true; + } + return Date.now() - settings.selfHostModeValidatedAt < SELF_HOST_GRACE_PERIOD_MS; +} + +/** + * Hook for paid status (any valid license, incl. Lite). Returns true when + * self-host mode is valid to allow offline usage. + */ +export function useIsPaidUser(): boolean | undefined { + const settings = useSettingsValue(); + if (hasSelfHostHookBypass(settings)) { + return true; + } + return settings.isPaidUser; +} + +/** + * Hook for tier >= Plus (excludes Lite) — the reactive gate for Plus-and-above + * features. Self-host plans are >= Plus, so the self-host bypass grants this too. */ export function useIsPlusUser(): boolean | undefined { const settings = useSettingsValue(); - // Self-host mode with valid plan validation bypasses Plus requirements (requires license key) - if ( - settings.plusLicenseKey && - settings.enableSelfHostMode && - settings.selfHostModeValidatedAt != null - ) { - // Permanently valid after 3 successful validations - if (settings.selfHostValidationCount >= SELF_HOST_PERMANENT_VALIDATION_COUNT) { - return true; - } - // Otherwise, check grace period - const isValid = Date.now() - settings.selfHostModeValidatedAt < SELF_HOST_GRACE_PERIOD_MS; - if (isValid) { - return true; - } + if (hasSelfHostHookBypass(settings)) { + return true; + } + if (isEntitlementExpired(settings)) { + return false; } return settings.isPlusUser; } /** - * Synchronous entitlement check for the multi-agent fan-out feature. Reuses the - * Plus entitlement (any paid plan) rather than a parallel notion of "paid". + * Synchronous entitlement check for the multi-agent fan-out feature. Gated on + * tier >= Plus (not merely "paid"), so Lite users are excluded. */ export function canUseMultiAgent(): boolean { return isPlusEnabled(); @@ -145,9 +215,11 @@ export function canUseMultiAgent(): boolean { * single source of truth the non-React session calls before dispatching, so a UI * bypass can't evade the paywall. * - * Fast path: a paying user (cached `isPlusEnabled()`) is allowed with no network - * call. Slow path: re-verify against `/license` so a stale-false cache still gets - * through; anything not confirmed valid is a HARD block (no single-agent fallback). + * Fast path: a Plus-tier user (cached `isPlusEnabled()`) is allowed with no + * network call. Slow path: re-verify against `/license` so a stale-false cache + * still gets through; we then re-read the freshly-applied entitlement (never the + * broad `isValid`) so Lite stays blocked. Anything not confirmed >= Plus is a + * HARD block (no single-agent fallback). */ export async function ensureMultiAgentEntitlement( app?: App, @@ -156,13 +228,13 @@ export async function ensureMultiAgentEntitlement( if (isPlusEnabled()) { return true; } - // Re-verify so a stale-false cache for a real paid user still gets through; - // `validateLicenseKey` flips the cached flag itself. - const result = await BrevilabsClient.getInstance().validateLicenseKey(app, { + // Re-verify so a stale-false cache for a real Plus user still gets through; + // `validateLicenseKey` applies the signed entitlement (or fallback) to settings. + await BrevilabsClient.getInstance().validateLicenseKey(app, { feature: "multi_agent_per_turn", ...context, }); - return result.isValid === true; + return isPlusEnabled(); } /** @@ -188,10 +260,10 @@ export function useCanUseMultiAgent(): boolean { } /** - * Check if the user is a Plus user. + * Check if the user has a valid paid license (any tier, incl. Lite). * When self-host mode is valid, this returns true to allow offline usage. */ -export async function checkIsPlusUser( +export async function checkIsPaidUser( app?: App, context?: Record ): Promise { @@ -201,7 +273,7 @@ export async function checkIsPlusUser( } if (!getSettings().plusLicenseKey) { - turnOffPlus(app); + turnOffPaid(app); return false; } const brevilabsClient = BrevilabsClient.getInstance(); @@ -453,23 +525,111 @@ export function navigateToPlusPage(medium: PlusUtmMedium): void { window.open(createPlusPageUrl(medium), "_blank"); } -export function turnOnPlus(): void { - updateSetting("isPlusUser", true); +/** + * Mark the user as paid via the no-token fallback path (valid license, but the + * server didn't issue a signed entitlement yet). Sets both flags true and clears + * any stale token. `isPlusUser` mirrors `isPaidUser` here because no sub-Plus + * paid tier exists until the server ships tokens + Lite/Pro together — so this is + * safe and preserves today's behavior. See "Copilot Entitlement Token Design". + */ +export function turnOnPaid(): void { + setSettings({ + isPaidUser: true, + isPlusUser: true, + entitlementToken: "", + entitlementExpiresAt: 0, + }); } /** - * Turn off Plus user status. - * IMPORTANT: This is called on every plugin start for users without a Plus license key (see checkIsPlusUser). - * DO NOT reset model settings here - it will cause free users to lose their model selections on every app restart. - * Only update the isPlusUser flag. + * Paid license confirmed by the server, but its entitlement token could not be + * verified (verifying key not shipped yet / kid rotation). Grant paid so general + * Plus features keep working, but WITHHOLD the strict gate — otherwise an + * unverifiable Lite token would bypass the multi-agent gate. Fails closed for + * multi-agent until the matching public key ships. */ -export function turnOffPlus(app?: App): void { - const previousIsPlusUser = getSettings().isPlusUser; - updateSetting("isPlusUser", false); +export function markPaidPendingEntitlement(): void { + setSettings({ + isPaidUser: true, + isPlusUser: false, + entitlementToken: "", + entitlementExpiresAt: 0, + }); +} + +/** + * Clear all entitlement state silently (no modal). Only invoked on an + * authoritative negative (invalid license / no license key) via turnOffPaid. + */ +function clearEntitlement(): void { + setSettings({ + isPaidUser: false, + isPlusUser: false, + entitlementToken: "", + entitlementExpiresAt: 0, + }); +} + +/** + * Turn off paid status. + * IMPORTANT: This is called on every plugin start for users without a license key (see checkIsPaidUser). + * DO NOT reset model settings here - it will cause free users to lose their model selections on every app restart. + * Only update the entitlement flags. + */ +export function turnOffPaid(app?: App): void { + const previousIsPaidUser = getSettings().isPaidUser; + clearEntitlement(); // The expiry modal needs `app`; interactive callers (load, settings, chat) - // pass it. Rare background paths (believer-model checks) flip the flag without - // a modal — they surface their own Notice instead. - if (previousIsPlusUser && app) { + // pass it. Rare background paths flip the flag without a modal — they surface + // their own Notice instead. + if (previousIsPaidUser && app) { new CopilotPlusExpiredModal(app).open(); } } + +/** + * Verify a signed entitlement token and, when valid, apply its claims to + * settings: `isPaidUser` = tier is not free, `isPlusUser` = the `multi_agent` + * capability is granted (tier >= Plus). + * + * Returns true when the token verified and was applied, false when it could NOT + * be verified (bad signature, expired, unknown `kid`, or — during rollout — an + * empty public-key set). An unverifiable token is NOT an authoritative "not + * entitled" signal, so this never clears flags; the caller decides the fallback + * (e.g. a license the server already confirmed valid stays paid). Only an + * authoritative negative (invalid license / no key) downgrades, via turnOffPaid. + */ +export async function applyEntitlement(token: string): Promise { + const claims = await verifyEntitlement(token, { expectedUserId: getSettings().userId }); + if (!claims) { + return false; + } + const grantsMultiAgent = claims.features.includes("multi_agent"); + setSettings({ + entitlementToken: token, + entitlementExpiresAt: claims.exp * 1000, + isPaidUser: claims.tier !== "free", + isPlusUser: grantsMultiAgent, + }); + strictEntitlementVerified = grantsMultiAgent; + return true; +} + +/** + * Re-verify the persisted entitlement token at startup so the strict Plus gate + * works offline WITHOUT trusting the persisted `isPlusUser` boolean. ES256 + * verification is offline (WebCrypto against the embedded public key), so a + * genuine token re-proves itself with no network, while an edited data.json + * (flipped booleans, future expiry, but no valid signature) leaves the in-memory + * proof false and the strict gate closed. The online `/license` re-validation + * still runs separately and overrides this with the server's fresh token. + */ +export async function verifyCachedEntitlement(): Promise { + const { entitlementToken, userId } = getSettings(); + if (!entitlementToken) { + strictEntitlementVerified = false; + return; + } + const claims = await verifyEntitlement(entitlementToken, { expectedUserId: userId }); + strictEntitlementVerified = claims?.features.includes("multi_agent") === true; +} diff --git a/src/settings/model.ts b/src/settings/model.ts index bf011abe..9021c067 100644 --- a/src/settings/model.ts +++ b/src/settings/model.ts @@ -118,8 +118,19 @@ export interface CopilotSettings { showSuggestedPrompts: boolean; numPartitions: number; defaultConversationNoteName: string; - // undefined means never checked + // Any valid paid license (Lite and above). undefined means never checked. + isPaidUser: boolean | undefined; + // Tier >= Plus (Plus, Pro, Believer, Supporter; excludes Lite). Derived from + // the signed entitlement token when present, else mirrors isPaidUser as a + // safe fallback. undefined means never checked. See plusUtils + entitlement/. isPlusUser: boolean | undefined; + // Raw server-signed entitlement token (JWS). Tamper-evident, so safe to persist + // and trust offline until its `exp`. Empty when the server hasn't issued one. + entitlementToken: string; + // Epoch ms when the entitlement token expires (0 = none / tokenless fallback). + // The strict isPlusUser gate honors this so multi-agent locks at expiry even + // while offline. Derived from the token's `exp`. + entitlementExpiresAt: number; inlineEditCommands: LegacyCommandSettings[] | undefined; projectList: Array; passMarkdownImages: boolean; @@ -623,6 +634,19 @@ export function sanitizeSettings(settings: CopilotSettings): CopilotSettings { sanitizedSettings.enableMiyo = legacyEnableMiyoSearch as boolean; } + // Migration: the old `isPlusUser` ("any valid license") was split into + // `isPaidUser` (any paid, incl. Lite) + a new `isPlusUser` (tier >= Plus, used + // by the multi-agent gate). Backfill `isPaidUser` from the legacy value. The + // legacy value is also a correct seed for the new strict `isPlusUser` because + // no sub-Plus paid tier existed before this split, so the carried-over + // `isPlusUser` stays correct until the next license validation. + if ( + typeof sanitizedSettings.isPaidUser !== "boolean" && + typeof rawSettings.isPlusUser === "boolean" + ) { + sanitizedSettings.isPaidUser = rawSettings.isPlusUser; + } + // Stuff in settings are string even when the interface has number type! const temperature = Number(settingsToSanitize.temperature); sanitizedSettings.temperature = isNaN(temperature) ? DEFAULT_SETTINGS.temperature : temperature; diff --git a/src/settings/v2/components/PlusSettings.tsx b/src/settings/v2/components/PlusSettings.tsx index 77101f2a..b477d6ae 100644 --- a/src/settings/v2/components/PlusSettings.tsx +++ b/src/settings/v2/components/PlusSettings.tsx @@ -4,7 +4,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { PasswordInput } from "@/components/ui/password-input"; import { PLUS_UTM_MEDIUMS } from "@/constants"; -import { checkIsPlusUser, navigateToPlusPage, useIsPlusUser } from "@/plusUtils"; +import { checkIsPaidUser, navigateToPlusPage, useIsPaidUser } from "@/plusUtils"; import { updateSetting, useSettingsValue } from "@/settings/model"; import { ExternalLink, Loader2 } from "lucide-react"; import React, { useState } from "react"; @@ -14,14 +14,14 @@ export function PlusSettings() { const settings = useSettingsValue(); const [error, setError] = useState(null); const [isChecking, setIsChecking] = useState(false); - const isPlusUser = useIsPlusUser(); + const isPaidUser = useIsPaidUser(); const [localLicenseKey, setLocalLicenseKey] = useState(settings.plusLicenseKey); return (
Copilot Plus - {isPlusUser && ( + {isPaidUser && ( Active @@ -56,7 +56,7 @@ export function PlusSettings() { onClick={async () => { updateSetting("plusLicenseKey", localLicenseKey); setIsChecking(true); - const result = await checkIsPlusUser(app); + const result = await checkIsPaidUser(app); setIsChecking(false); if (!result) { setError("Invalid license key"); diff --git a/src/tests/projectContextCache.test.ts b/src/tests/projectContextCache.test.ts index a38bafc5..3301fb17 100644 --- a/src/tests/projectContextCache.test.ts +++ b/src/tests/projectContextCache.test.ts @@ -49,6 +49,7 @@ jest.mock("@/utils/hash", () => ({ // Mock plusUtils jest.mock("@/plusUtils", () => ({ + useIsPaidUser: jest.fn(), useIsPlusUser: jest.fn(), navigateToPlusPage: jest.fn(), }));