@chatpanel/events 0.46.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/entitlement.js +298 -0
- package/index.js +11 -0
- package/package.json +3 -1
package/entitlement.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// PLANS, GATES AND THE SIGNED ENTITLEMENT — one definition, every client.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS SHARED, and it is the sharpest example in the package. "Is this user Pro" is
|
|
4
|
+
// currently answered in four places: the extension (`js/license.js`), the bridge
|
|
5
|
+
// (`src/entitlement.js`), the gateway (`src/entitlement.js`) and now a desktop app. Three of
|
|
6
|
+
// those already carry their own copy of the SAME public key, which is why `CLAUDE.md` has to
|
|
7
|
+
// warn that rotating the signing key means editing every client by hand.
|
|
8
|
+
//
|
|
9
|
+
// Duplicating a feature matrix is worse than duplicating a helper: it does not fail loudly,
|
|
10
|
+
// it fails as "Pro works in the browser but not on the desktop", which reads to the person
|
|
11
|
+
// paying for it as the product being broken. Defining it once makes the two clients agree
|
|
12
|
+
// BY CONSTRUCTION rather than by diligence.
|
|
13
|
+
//
|
|
14
|
+
// WHAT IS STILL THE CLIENT'S JOB (P8, as everywhere else): storing the licence, minting and
|
|
15
|
+
// keeping an install id, opening a browser for checkout, and counting local usage against
|
|
16
|
+
// the free limits. Those need storage and a platform. Everything here is pure.
|
|
17
|
+
//
|
|
18
|
+
// THE PRIVATE KEY IS NOT HERE AND NEVER WILL BE. The worker holds it; this file holds only
|
|
19
|
+
// the public half, which is what lets every client verify OFFLINE — chat traffic never
|
|
20
|
+
// touches the licence server, and a client that cannot reach the network keeps working.
|
|
21
|
+
|
|
22
|
+
export class EntitlementError extends Error {
|
|
23
|
+
constructor(message) { super(message); this.name = 'EntitlementError'; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const PLANS = Object.freeze(['free', 'pro', 'team']);
|
|
27
|
+
const RANK = Object.freeze({ free: 0, pro: 1, team: 2 });
|
|
28
|
+
|
|
29
|
+
/** Where the licence server lives. One base; the endpoints derive from it. */
|
|
30
|
+
export const API_BASE = 'https://api.chatpanel.net';
|
|
31
|
+
export const ENDPOINTS = Object.freeze({
|
|
32
|
+
verify: `${API_BASE}/license/verify`,
|
|
33
|
+
entitlement: `${API_BASE}/entitlement`,
|
|
34
|
+
claim: `${API_BASE}/entitlement/claim`,
|
|
35
|
+
release: `${API_BASE}/entitlement/release`,
|
|
36
|
+
restore: `${API_BASE}/restore`,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The PUBLIC half of the entitlement signing key.
|
|
41
|
+
*
|
|
42
|
+
* Byte-for-byte the key the extension and bridge already embed. If it is ever rotated it is
|
|
43
|
+
* rotated HERE, and every client that takes this package inherits it on its next sync —
|
|
44
|
+
* which is the entire reason this module exists.
|
|
45
|
+
*/
|
|
46
|
+
export const ENTITLEMENT_PUBLIC_JWK = Object.freeze({
|
|
47
|
+
kty: 'EC',
|
|
48
|
+
crv: 'P-256',
|
|
49
|
+
x: 'CmgKLC4e3xDMvwhbjVqF7jbDe1JhC1KKQi8JN3qVX_4',
|
|
50
|
+
y: 'r40l6fQiyCcJYqW-SvB4VoSyn4F36yhSt82ZAOSo78E',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** Where "Upgrade" sends people. A stable URL, so pricing can change without a release. */
|
|
54
|
+
export const UPGRADE_URL = 'https://chatpanel.net/#pricing';
|
|
55
|
+
|
|
56
|
+
export function checkoutUrl(plan = 'pro', installId = '', client = '') {
|
|
57
|
+
const u = new URL('https://chatpanel.net/');
|
|
58
|
+
if (installId) u.searchParams.set('install_id', installId);
|
|
59
|
+
if (plan) u.searchParams.set('plan', plan);
|
|
60
|
+
// Which client sent them, so a desktop purchase can be attributed and, later, seated
|
|
61
|
+
// differently from a browser one. Additive: the site ignores what it does not read.
|
|
62
|
+
if (client) u.searchParams.set('client', client);
|
|
63
|
+
u.hash = 'pricing';
|
|
64
|
+
return u.toString();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --------------------------------------------------------------------------
|
|
68
|
+
// The matrix
|
|
69
|
+
// --------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every gated feature → the minimum plan that unlocks it. Anything absent is free.
|
|
73
|
+
*
|
|
74
|
+
* Features a given client cannot perform are still listed: the desktop cannot capture a
|
|
75
|
+
* meeting, but it can READ one, and `liveMeetings` gates the dashboard and history search
|
|
76
|
+
* as well as capture. A client omitting a key it "does not need" is how the two drift.
|
|
77
|
+
*/
|
|
78
|
+
export const FEATURE_TIER = Object.freeze({
|
|
79
|
+
localAgents: 'free',
|
|
80
|
+
byoModels: 'free',
|
|
81
|
+
urlContext: 'free',
|
|
82
|
+
liveMeetings: 'free',
|
|
83
|
+
|
|
84
|
+
multiTab: 'pro',
|
|
85
|
+
unlimitedAgents: 'pro',
|
|
86
|
+
customSkills: 'pro',
|
|
87
|
+
customAgents: 'pro',
|
|
88
|
+
advancedAgent: 'pro',
|
|
89
|
+
unlimitedNotes: 'pro',
|
|
90
|
+
unlimitedMeetings: 'pro',
|
|
91
|
+
structuredInsert: 'pro',
|
|
92
|
+
exportChats: 'pro',
|
|
93
|
+
autoBackup: 'pro',
|
|
94
|
+
promptLibrary: 'pro',
|
|
95
|
+
fileAttachments: 'pro',
|
|
96
|
+
watch: 'pro',
|
|
97
|
+
|
|
98
|
+
cloudSync: 'team',
|
|
99
|
+
sharedLibrary: 'team',
|
|
100
|
+
hostedBridge: 'team',
|
|
101
|
+
sso: 'team',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const PRO_FEATURES = Object.freeze({
|
|
105
|
+
unlimitedNotes: 'Unlimited notes — Free keeps your first 10',
|
|
106
|
+
unlimitedMeetings: 'Unlimited meetings — Free keeps your first 10',
|
|
107
|
+
multiTab: 'Attach several tabs at once',
|
|
108
|
+
unlimitedAgents: 'Unlimited custom agents',
|
|
109
|
+
customSkills: 'Create & edit your own skills',
|
|
110
|
+
advancedAgent: 'Per-agent system prompts & working directories',
|
|
111
|
+
structuredInsert: 'Clean diagrams on Excalidraw, draw.io & tldraw — shapes placed as data, not pixel-drawn',
|
|
112
|
+
exportChats: 'Export conversations as Markdown',
|
|
113
|
+
autoBackup: 'Automatic daily backup of all your data to disk',
|
|
114
|
+
watch: 'Watch a page & act on changes — the agent reacts as the page updates',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export const TEAM_FEATURES = Object.freeze({
|
|
118
|
+
cloudSync: 'Sync chats across your devices',
|
|
119
|
+
sharedLibrary: 'Shared team agents & skills',
|
|
120
|
+
hostedBridge: 'Hosted agents — no local bridge to run',
|
|
121
|
+
sso: 'SSO & admin controls',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** Free-tier ceilings. LIFETIME counts where noted — see the anti-cheat note in each client. */
|
|
125
|
+
export const FREE_LIMITS = Object.freeze({
|
|
126
|
+
notes: 10,
|
|
127
|
+
meetings: 10,
|
|
128
|
+
apiEndpoints: 1,
|
|
129
|
+
bridgeAgents: 1,
|
|
130
|
+
customAgents: 1,
|
|
131
|
+
attachmentsPerMessage: 1,
|
|
132
|
+
mcpServers: 1,
|
|
133
|
+
gatewayDestinations: 1,
|
|
134
|
+
webSearchEngines: 3,
|
|
135
|
+
webSearchesPerDay: 50,
|
|
136
|
+
fullRedactions: 25,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// --------------------------------------------------------------------------
|
|
140
|
+
// Resolution
|
|
141
|
+
// --------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The plan actually in force.
|
|
145
|
+
*
|
|
146
|
+
* An expired licence is `free`, checked against an injected `now` so a client cannot be
|
|
147
|
+
* tested only at the moment it happens to be run.
|
|
148
|
+
*/
|
|
149
|
+
export function planOf(license, now = Date.now()) {
|
|
150
|
+
if (!license || !PLANS.includes(license.plan)) return 'free';
|
|
151
|
+
if (license.expiresAt && now > license.expiresAt) return 'free';
|
|
152
|
+
return license.plan;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function planLabel(license, now = Date.now()) {
|
|
156
|
+
return { free: 'Free', pro: 'Pro', team: 'Team' }[planOf(license, now)];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isPro(license, now = Date.now()) {
|
|
160
|
+
return RANK[planOf(license, now)] >= RANK.pro;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function isTeam(license, now = Date.now()) {
|
|
164
|
+
return planOf(license, now) === 'team';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Gate a feature. `count` expresses "this would be my Nth", for the counted allowances. */
|
|
168
|
+
export function can(license, feature, count = 0, now = Date.now()) {
|
|
169
|
+
const plan = planOf(license, now);
|
|
170
|
+
if (feature === 'unlimitedAgents' && RANK[plan] < RANK.pro) {
|
|
171
|
+
return count < FREE_LIMITS.customAgents;
|
|
172
|
+
}
|
|
173
|
+
const need = FEATURE_TIER[feature] || 'free';
|
|
174
|
+
return RANK[plan] >= RANK[need];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The minimum plan a feature needs — for an upgrade prompt that names the right tier. */
|
|
178
|
+
export function tierFor(feature) {
|
|
179
|
+
return FEATURE_TIER[feature] || 'free';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Is this the Nth use of a lifetime-capped free allowance?
|
|
184
|
+
*
|
|
185
|
+
* The count is MONOTONIC — "how many have ever been created", not "how many exist now" —
|
|
186
|
+
* because a cap on the current count is lifted by deleting things, which is not a limit.
|
|
187
|
+
*/
|
|
188
|
+
export function withinFreeLimit(license, key, everCount, now = Date.now()) {
|
|
189
|
+
if (isPro(license, now)) return true;
|
|
190
|
+
const cap = FREE_LIMITS[key];
|
|
191
|
+
return typeof cap !== 'number' || everCount < cap;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --------------------------------------------------------------------------
|
|
195
|
+
// The signed token
|
|
196
|
+
// --------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
function b64urlToBytes(s) {
|
|
199
|
+
const pad = '='.repeat((4 - (String(s).length % 4)) % 4);
|
|
200
|
+
const b64 = String(s).replace(/-/g, '+').replace(/_/g, '/') + pad;
|
|
201
|
+
if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
202
|
+
const bin = atob(b64);
|
|
203
|
+
const out = new Uint8Array(bin.length);
|
|
204
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function decodePayload(head) {
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(new TextDecoder().decode(b64urlToBytes(head)));
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let _keyPromise = null;
|
|
217
|
+
|
|
218
|
+
async function verifyKey(subtle) {
|
|
219
|
+
const s = subtle || globalThis.crypto?.subtle;
|
|
220
|
+
if (!s) throw new EntitlementError('no WebCrypto available on this runtime');
|
|
221
|
+
if (subtle) {
|
|
222
|
+
// An injected implementation must not be memoised into the shared slot, or a test that
|
|
223
|
+
// passes one would poison every later call in the process.
|
|
224
|
+
return s.importKey('jwk', ENTITLEMENT_PUBLIC_JWK, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
|
|
225
|
+
}
|
|
226
|
+
if (!_keyPromise) {
|
|
227
|
+
_keyPromise = s.importKey('jwk', ENTITLEMENT_PUBLIC_JWK, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
|
|
228
|
+
}
|
|
229
|
+
return _keyPromise;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Verify a server entitlement token and return its payload, or `null`.
|
|
234
|
+
*
|
|
235
|
+
* Three checks, and all three matter: the SIGNATURE (it came from the worker), the INSTALL
|
|
236
|
+
* BINDING (it was issued to this device, so a token copied from a forum does not grant Pro),
|
|
237
|
+
* and EXPIRY. Returning null rather than throwing is deliberate — a caller resolving a
|
|
238
|
+
* licence at startup must degrade to free, not crash the app.
|
|
239
|
+
*/
|
|
240
|
+
export async function verifyEntitlement(token, installId, { subtle = null, now = Date.now() } = {}) {
|
|
241
|
+
if (!token || typeof token !== 'string' || token.indexOf('.') < 0) return null;
|
|
242
|
+
const [head, sig] = token.split('.');
|
|
243
|
+
if (!head || !sig) return null;
|
|
244
|
+
|
|
245
|
+
let ok = false;
|
|
246
|
+
try {
|
|
247
|
+
const s = subtle || globalThis.crypto?.subtle;
|
|
248
|
+
ok = await s.verify(
|
|
249
|
+
{ name: 'ECDSA', hash: 'SHA-256' },
|
|
250
|
+
await verifyKey(subtle),
|
|
251
|
+
b64urlToBytes(sig),
|
|
252
|
+
new TextEncoder().encode(head),
|
|
253
|
+
);
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
if (!ok) return null;
|
|
258
|
+
|
|
259
|
+
const payload = decodePayload(head);
|
|
260
|
+
if (!payload) return null;
|
|
261
|
+
if (payload.exp && now > Number(payload.exp)) return null;
|
|
262
|
+
// `install` binds the token to one device. A token with no binding is refused rather than
|
|
263
|
+
// treated as universal — "unbound" must never be the permissive case.
|
|
264
|
+
if (!payload.install || (installId && payload.install !== installId)) return null;
|
|
265
|
+
if (!PLANS.includes(payload.plan)) return null;
|
|
266
|
+
return payload;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** A verified payload → the licence record a client stores. */
|
|
270
|
+
export function licenseFromPayload(payload, { at = Date.now() } = {}) {
|
|
271
|
+
if (!payload) return { plan: 'free' };
|
|
272
|
+
return {
|
|
273
|
+
plan: payload.plan,
|
|
274
|
+
expiresAt: Number(payload.exp) || 0,
|
|
275
|
+
installId: payload.install || '',
|
|
276
|
+
seats: Number(payload.seats) || 0,
|
|
277
|
+
checkedAt: at,
|
|
278
|
+
source: payload.source || 'entitlement',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Should this client re-check with the server yet?
|
|
284
|
+
*
|
|
285
|
+
* Offline verification is the point, so the cadence is generous — but a licence that has
|
|
286
|
+
* NEVER been checked, or one whose expiry is close, is worth a call. Pure so the schedule is
|
|
287
|
+
* testable without waiting a day.
|
|
288
|
+
*/
|
|
289
|
+
export const RECHECK_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
|
290
|
+
|
|
291
|
+
export function needsRecheck(license, { now = Date.now(), interval = RECHECK_INTERVAL_MS } = {}) {
|
|
292
|
+
if (!license || !license.checkedAt) return true;
|
|
293
|
+
if (now - license.checkedAt > interval) return true;
|
|
294
|
+
// Inside the last day of validity, check more eagerly: this is where a renewal lands and
|
|
295
|
+
// where a silent lapse would otherwise surprise someone mid-sentence.
|
|
296
|
+
if (license.expiresAt && license.expiresAt - now < 24 * 60 * 60 * 1000) return true;
|
|
297
|
+
return false;
|
|
298
|
+
}
|
package/index.js
CHANGED
|
@@ -197,3 +197,14 @@ export {
|
|
|
197
197
|
THEMES, LIGHT, DARK, PALETTES, SHAPE, TOKEN_NAMES, TOKEN_ROLES,
|
|
198
198
|
paletteFor, cssVarName, toCssVars, themeStylesheet, resolveTheme,
|
|
199
199
|
} from './theme.js';
|
|
200
|
+
|
|
201
|
+
// Plans, gates and the signed entitlement — one definition, every client. The public
|
|
202
|
+
// verification key lives here so a rotation reaches all of them, instead of being a
|
|
203
|
+
// hand-edit in each (which is what CLAUDE.md currently has to warn about).
|
|
204
|
+
export {
|
|
205
|
+
PLANS, API_BASE, ENDPOINTS, ENTITLEMENT_PUBLIC_JWK, UPGRADE_URL,
|
|
206
|
+
FEATURE_TIER, PRO_FEATURES, TEAM_FEATURES, FREE_LIMITS,
|
|
207
|
+
RECHECK_INTERVAL_MS, EntitlementError,
|
|
208
|
+
checkoutUrl, planOf, planLabel, isPro, isTeam, can, tierFor, withinFreeLimit,
|
|
209
|
+
verifyEntitlement, licenseFromPayload, needsRecheck,
|
|
210
|
+
} from './entitlement.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.0",
|
|
4
4
|
"description": "The canonical ChatPanel event-log and capability contracts — typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"./citations.js": "./citations.js",
|
|
13
13
|
"./curate.js": "./curate.js",
|
|
14
14
|
"./distance.js": "./distance.js",
|
|
15
|
+
"./entitlement.js": "./entitlement.js",
|
|
15
16
|
"./entity.js": "./entity.js",
|
|
16
17
|
"./event.js": "./event.js",
|
|
17
18
|
"./extraction.js": "./extraction.js",
|
|
@@ -81,6 +82,7 @@
|
|
|
81
82
|
"citations.js",
|
|
82
83
|
"curate.js",
|
|
83
84
|
"distance.js",
|
|
85
|
+
"entitlement.js",
|
|
84
86
|
"entity.js",
|
|
85
87
|
"event.js",
|
|
86
88
|
"extraction.js",
|