logancyang_obsidian-copilot/src/modelManagement
Logan Yang a78b38c8c2
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>
2026-06-29 19:56:00 -07:00
..
backends BYOK model management 2026-05-28 16:02:48 -07:00
catalog BYOK model management 2026-05-28 16:02:48 -07:00
chatModel feat(models): flag non-vision models, guard image sends (#2627) 2026-06-29 00:02:47 -07:00
models Add provider and configured model registry 2026-05-28 16:00:38 -07:00
providers fix(agent-mode): surface missing provider credentials (#2529) 2026-05-29 23:39:18 -07:00
setup feat(entitlement): gate multi-agent to Plus tier via signed entitlement token (#2644) 2026-06-29 19:56:00 -07:00
state BYOK model management 2026-05-28 16:02:48 -07:00
types fix(agent-mode): surface missing provider credentials (#2529) 2026-05-29 23:39:18 -07:00
ui feat(models): flag non-vision models, guard image sends (#2627) 2026-06-29 00:02:47 -07:00
AGENTS.md Add BYOK settings - agent enablement 2026-05-28 16:02:47 -07:00
CLAUDE.md boostrap new model managmeent 2026-05-28 16:00:38 -07:00
createModelManagement.test.ts BYOK model management 2026-05-28 16:02:48 -07:00
createModelManagement.ts BYOK model management 2026-05-28 16:02:48 -07:00
index.ts feat(models): flag non-vision models, guard image sends (#2627) 2026-06-29 00:02:47 -07:00