feat(entitlement): gate multi-agent to Plus tier via signed entitlement token (#2644)

* feat(entitlement): gate multi-agent to Plus tier via signed entitlement token

Multi-agent fan-out was gated on the binary isPlusUser flag, which any valid
license flips true, so a future Lite tier would wrongly pass. Introduce a
server-signed entitlement token (ES256, verified offline via WebCrypto) as the
single entitlement primitive.

- Rename the broad flag isPlusUser to isPaidUser (any paid license, incl. Lite);
  all existing Plus-feature gates move to it, preserving current behavior.
- Add a new strict isPlusUser (tier >= Plus) derived from the token's
  multi_agent feature. The multi-agent UI gate and the authoritative
  send-boundary check (ensureMultiAgentEntitlement) now key on it.
- No-token fallback (isPlusUser = isPaidUser) preserves today's behavior until
  the server ships tokens alongside Lite/Pro, so there is no plan-name list in
  the public client to patch out.
- Settings sanitize migration backfills isPaidUser from the legacy isPlusUser.
- src/entitlement/ verifies the JWS signature with an embedded public key (no
  key can mint tokens client-side); forged data.json / faked responses fail.

Tests cover tier gating across Free/Lite/Plus/Pro plus the crypto layer
(valid, expired, wrong-user, tampered, unknown-kid, malformed).

Self-host migration to the token is deferred to logancyang/obsidian-copilot-preview#201.
Closes logancyang/obsidian-copilot-preview#200.
Design: "Copilot Entitlement Token Design".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(test): swap in Node WebCrypto when jsdom's subtle lacks generateKey

CI (Node 22) ships a jsdom whose window.crypto.subtle exists but is partial
(no generateKey), so the previous `!subtle` guard skipped the polyfill and the
entitlement crypto tests failed. Probe for the actual method and install Node's
complete WebCrypto when it's missing. Runtime is unaffected (test-only setup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(entitlement): never downgrade on an unverifiable token (Codex P1/P2)

Addresses two Codex findings:

- P1: when /license starts returning a token but the client's public keys are
  not shipped yet (or during kid rotation), every token verified as null and
  applyEntitlement cleared the flags, dropping paying users to free. Now
  applyEntitlement never clears: it returns false when it cannot verify, and
  validateLicenseKey falls back to turnOnPaid() for a license the server already
  confirmed valid. Only an authoritative negative (invalid license / no key)
  downgrades, via turnOffPaid.
- P2: removed refreshEntitlementFromCache() and its concurrent startup call,
  which could clear flags after checkIsPaidUser() restored them. The persisted
  flags already hold the last verdict and the online check is authoritative;
  offline expiry enforcement belongs to the deferred self-host migration (#201).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(test): run entitlement crypto test in node env, not flaky jsdom subtle

jsdom ships only a partial SubtleCrypto (no generateKey), and patching it in
jest.setup was nondeterministic across CI Node builds (passed on one commit,
failed on the next with no setup change). Run verify.test under the node
environment, where crypto.subtle is Node's complete WebCrypto — the verification
logic is WebCrypto-spec behavior, identical between Node and the webview.

Also drop verify.ts's @/logger import so the entitlement module is a pure leaf
(no settings/obsidian graph), which keeps the node-env test import-safe and
matches the module's intended dependency-light design. jest.setup now guards its
window usage so it is a no-op under the node environment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(test): force WebCrypto onto globalThis, not just window

The prior fix patched window.crypto, but a bare `crypto` reference in a module
resolves to globalThis.crypto, and jest's jsdom environment installs a partial
SubtleCrypto (no generateKey) on globalThis that the window-only patch missed —
so CI kept failing in jsdom while local (clean jsdom) passed. Patch globalThis
(and window) with Node's complete WebCrypto, with a fallback to replacing
`.subtle` if `crypto` is a locked accessor. Reverts the node-env docblock on the
crypto test; the global patch covers the standard jsdom env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(test): inject Node WebCrypto into entitlement verify instead of patching globals

Root cause of the repeated CI failures: jest's jsdom environment exposes a
partial, locked SubtleCrypto (no generateKey/ECDSA) on the global the module
resolves `crypto` from, and neither replacing window/globalThis.crypto nor the
@jest-environment node docblock reliably overrode it on CI (local jsdom resolves
clean, so it couldn't be reproduced locally).

Fix: give verifyEntitlement an injectable `subtle` option (default
`crypto.subtle`, as used at runtime on desktop + mobile), and have the test pass
Node's webcrypto.subtle explicitly. The test no longer depends on the
environment's global WebCrypto at all, so it's deterministic in any jest env.
jest.setup reverted to its original form (no crypto hacks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(entitlement): close 3 Codex findings (Lite bypass, log leak, offline expiry)

- P1 (Lite bypass): an unverifiable entitlement token no longer grants the strict
  gate. validateLicenseKey now calls markPaidPendingEntitlement() — paid so general
  Plus features work, but isPlusUser stays false so an unverifiable Lite token
  can't pass the multi-agent gate before the verifying key ships (fails closed).
  Tokenless (old server) still grants paid + Plus.
- P2 (log leak): parseBrevilabsResponse redacts the `entitlement` JWS before
  logInfo, so it never lands in the shared copilot-log.md.
- P2 (offline expiry): persist the token's exp as entitlementExpiresAt and have
  the strict gate (isPlusEnabled / useIsPlusUser) honor it, so multi-agent locks
  at expiry even while /license is unavailable. The broad paid gate keeps legacy
  behavior. ensureMultiAgentEntitlement's slow path re-reads it, so an expired
  token offline is blocked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* feat(entitlement): embed prod ES256 public key (kid ent-2026-06)

Populate ENTITLEMENT_PUBLIC_KEYS with the production verify-only public
JWK so verifyEntitlement can validate server-signed tokens offline. The
matching private key signs tokens in brevilabs-api.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(entitlement): require in-session signature proof for strict Plus

Address Codex P1: isPlusEnabled() trusted the persisted isPlusUser
boolean + entitlementExpiresAt without re-verifying the JWS, so an
edited data.json (isPlusUser: true + future expiry) bypassed the
multi-agent gate offline and in the startup window. The signed token was
not actually the gate on cached state.

Gate token-derived strict Plus on an in-memory, non-persisted proof that
a signed entitlement granting multi_agent was cryptographically verified
in THIS process — set by applyEntitlement (server response) or the new
verifyCachedEntitlement (cached token re-checked at startup, offline via
WebCrypto). A persisted boolean alone never sets it, so it fails closed.

The tokenless fallback (pre-token server, entitlementExpiresAt === 0) is
unchanged: no signed artifact exists to verify, so it honors the
license-confirmed flag as today and preserves new-plugin/old-server
compatibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Logan Yang 2026-06-29 19:56:00 -07:00 committed by GitHub
parent bd60f10f56
commit a78b38c8c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 803 additions and 126 deletions

View file

@ -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<T>(
}
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<string, unknown>), 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 };
}

View file

@ -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

View file

@ -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

View file

@ -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<typeof checkIsPlusUser>;
const mockCheckIsPaidUser = checkIsPaidUser as jest.MockedFunction<typeof checkIsPaidUser>;
const mockCallTool = ToolManager.callTool as jest.MockedFunction<typeof ToolManager.callTool>;
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();
});

View file

@ -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`,

View file

@ -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;
}

View file

@ -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");
}

View file

@ -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);
});

View file

@ -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);

View file

@ -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",
});

View file

@ -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) {

View file

@ -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) {

View file

@ -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<string, unknown>;
beforeEach(() => {
jest.resetModules();
@ -1087,6 +1091,7 @@ describe("ensureMultiAgentEntitlement (paywall helper)", () => {
async function loadHelper(
isPlus: boolean
): Promise<(app?: unknown, ctx?: Record<string, unknown>) => Promise<boolean>> {
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<string, unknown>) => 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);

View file

@ -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;
}

View file

@ -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<ChatProps & { chatInput: ReturnType<typeof useChatI
const [previousMode, setPreviousMode] = useState<ChainType | null>(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<ChatProps & { chatInput: ReturnType<typeof useChatI
} else {
// default back to chat or plus mode
setSelectedChain(
isPlusUser ? ChainType.COPILOT_PLUS_CHAIN : ChainType.LLM_CHAIN
isPaidUser ? ChainType.COPILOT_PLUS_CHAIN : ChainType.LLM_CHAIN
);
}
}}

View file

@ -9,7 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { PLUS_UTM_MEDIUMS } from "@/constants";
import { logError } from "@/logger";
import { shouldUseMiyo } from "@/miyo/miyoUtils";
import { navigateToPlusPage, useIsPlusUser } from "@/plusUtils";
import { navigateToPlusPage, useIsPaidUser } from "@/plusUtils";
import { updateSetting, useSettingsValue } from "@/settings/model";
import { Docs4LLMParser } from "@/tools/FileParserManager";
import { isRateLimitError } from "@/utils/rateLimitUtils";
@ -230,7 +230,7 @@ export function ChatControls({
const app = useApp();
const settings = useSettingsValue();
const [selectedChain, setSelectedChain] = useChainType();
const isPlusUser = useIsPlusUser();
const isPaidUser = useIsPaidUser();
const handleModeChange = async (chainType: ChainType) => {
// If leaving project mode with autosave enabled, save chat BEFORE clearing project context
@ -282,7 +282,7 @@ export function ChatControls({
>
vault QA (free)
</DropdownMenuItem>
{isPlusUser ? (
{isPaidUser ? (
<DropdownMenuItem
onSelect={() => {
void handleModeChange(ChainType.COPILOT_PLUS_CHAIN);
@ -305,7 +305,7 @@ export function ChatControls({
</DropdownMenuItem>
)}
{isPlusUser ? (
{isPaidUser ? (
<DropdownMenuItem
className="tw-flex tw-items-center tw-gap-1"
onSelect={() => {

View file

@ -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: "",

4
src/entitlement/index.ts Normal file
View file

@ -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";

View file

@ -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<string, JsonWebKey> = {
"ent-2026-06": {
kty: "EC",
crv: "P-256",
x: "9j3HVz0TWJ5VFMTiaNhExKQJAPWjduz2wdZpKAXIkzk",
y: "ZA9ARslf7l1XUK5zL5Tgbm-eY-zxWaVLzQD7nTnId6Y",
},
};

25
src/entitlement/types.ts Normal file
View file

@ -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;
}

View file

@ -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<VerifyEntitlementOptions, "subtle"> = {}) {
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<string, unknown>,
header: Record<string, unknown> = { alg: "ES256", typ: "JWT", kid: KID }
): Promise<string> {
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<EntitlementClaims> = {}): Record<string, unknown> {
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<string, JsonWebKey>;
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();
});
});

104
src/entitlement/verify.ts Normal file
View file

@ -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<string, JsonWebKey>;
/** 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<T>(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<EntitlementClaims | null> {
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<JwsHeader>(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<EntitlementClaims>(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;
}

View file

@ -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<void> = 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

View file

@ -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

View file

@ -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<void> {
try {
if (isPlusUser && licenseKey) {
if (isPaidUser && licenseKey) {
const token = await getDecryptedKey(licenseKey);
await api.setup.copilotPlus.registerPlusProvider({
providerType: "openai-compatible",

View file

@ -2,12 +2,26 @@ import { DEFAULT_SETTINGS } from "@/constants";
import type { CopilotSettings } from "@/settings/model";
const mockGetSettings = jest.fn<CopilotSettings, []>();
const mockSetSettings = jest.fn<void, [Partial<CopilotSettings>]>();
jest.mock("@/settings/model", () => ({
getSettings: () => mockGetSettings(),
setSettings: (partial: Partial<CopilotSettings>) => mockSetSettings(partial),
}));
import { canUseMultiAgent, isSelfHostAccessValid, isSelfHostModeValid } from "@/plusUtils";
const mockVerifyEntitlement = jest.fn<Promise<unknown>, [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", () => {

View file

@ -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<string, unknown>
): Promise<boolean | undefined> {
@ -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<boolean> {
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<void> {
const { entitlementToken, userId } = getSettings();
if (!entitlementToken) {
strictEntitlementVerified = false;
return;
}
const claims = await verifyEntitlement(entitlementToken, { expectedUserId: userId });
strictEntitlementVerified = claims?.features.includes("multi_agent") === true;
}

View file

@ -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<ProjectConfig>;
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;

View file

@ -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<string | null>(null);
const [isChecking, setIsChecking] = useState(false);
const isPlusUser = useIsPlusUser();
const isPaidUser = useIsPaidUser();
const [localLicenseKey, setLocalLicenseKey] = useState(settings.plusLicenseKey);
return (
<section className="tw-flex tw-flex-col tw-gap-4 tw-rounded-lg tw-bg-secondary tw-p-4">
<div className="tw-flex tw-items-center tw-justify-between tw-gap-2 tw-text-xl tw-font-bold">
<span>Copilot Plus</span>
{isPlusUser && (
{isPaidUser && (
<Badge variant="outline" className="tw-text-success">
Active
</Badge>
@ -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");

View file

@ -49,6 +49,7 @@ jest.mock("@/utils/hash", () => ({
// Mock plusUtils
jest.mock("@/plusUtils", () => ({
useIsPaidUser: jest.fn(),
useIsPlusUser: jest.fn(),
navigateToPlusPage: jest.fn(),
}));