@bugb-technologies/bravos 0.0.0-stage → 1.0.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.
Files changed (85) hide show
  1. package/LICENSE +70 -0
  2. package/README.md +225 -2
  3. package/dist/account.js +140 -0
  4. package/dist/artifacts.js +450 -0
  5. package/dist/ci.js +52 -0
  6. package/dist/cli.js +963 -0
  7. package/dist/codegraph.js +268 -0
  8. package/dist/cursor.js +70 -0
  9. package/dist/detect.js +26 -0
  10. package/dist/doctor.js +99 -0
  11. package/dist/global.js +95 -0
  12. package/dist/graphbin.js +136 -0
  13. package/dist/guardlink.js +123 -0
  14. package/dist/guide.js +255 -0
  15. package/dist/help.js +334 -0
  16. package/dist/hooks.js +167 -0
  17. package/dist/install.js +209 -0
  18. package/dist/licence.js +41 -0
  19. package/dist/lock.js +50 -0
  20. package/dist/mcp.js +139 -0
  21. package/dist/migrate.js +275 -0
  22. package/dist/omp.js +46 -0
  23. package/dist/opencode.js +118 -0
  24. package/dist/paths.js +33 -0
  25. package/dist/plugin.js +101 -0
  26. package/dist/report/check.js +252 -0
  27. package/dist/report/data.js +401 -0
  28. package/dist/report/export.js +105 -0
  29. package/dist/report/markdown.js +310 -0
  30. package/dist/report/meta.js +48 -0
  31. package/dist/report/pdf.js +96 -0
  32. package/dist/review-server.js +7 -0
  33. package/dist/review.js +274 -0
  34. package/dist/setup.js +121 -0
  35. package/dist/skills.js +81 -0
  36. package/dist/types.js +9 -0
  37. package/dist/uninstall.js +192 -0
  38. package/dist/versions.js +8 -0
  39. package/dist/watch-frame.js +593 -0
  40. package/dist/watch.js +873 -0
  41. package/dist/workspace.js +316 -0
  42. package/docs/GUIDE.md +323 -0
  43. package/hooks/autopilot.mjs +45 -0
  44. package/hooks/lib/autopilot.mjs +191 -0
  45. package/hooks/lib/briefing.mjs +750 -0
  46. package/hooks/lib/licence.mjs +452 -0
  47. package/hooks/lib/pre-pr.mjs +209 -0
  48. package/hooks/lib/sast.mjs +207 -0
  49. package/hooks/lib/setup.mjs +107 -0
  50. package/hooks/lib/workspace.mjs +78 -0
  51. package/hooks/pre-pr.mjs +54 -0
  52. package/hooks/session-start.mjs +82 -0
  53. package/package.json +49 -4
  54. package/skills/bravos/SKILL.md +63 -0
  55. package/skills/bravos-annotate/SKILL.md +56 -0
  56. package/skills/bravos-annotate/scripts/lib/pick.mjs +127 -0
  57. package/skills/bravos-annotate/scripts/slice.mjs +141 -0
  58. package/skills/bravos-campaign/SKILL.md +49 -0
  59. package/skills/bravos-cicd/SKILL.md +48 -0
  60. package/skills/bravos-codegraph/SKILL.md +57 -0
  61. package/skills/bravos-context/SKILL.md +51 -0
  62. package/skills/bravos-context/intent.example.md +48 -0
  63. package/skills/bravos-env/SKILL.md +48 -0
  64. package/skills/bravos-env/scope.example.yaml +50 -0
  65. package/skills/bravos-prove/SKILL.md +52 -0
  66. package/skills/bravos-recheck/SKILL.md +47 -0
  67. package/skills/bravos-report/SKILL.md +53 -0
  68. package/skills/bravos-report/assessment.md +54 -0
  69. package/skills/bravos-report/brief.md +23 -0
  70. package/skills/bravos-report/finding.md +74 -0
  71. package/skills/bravos-report/retest.md +29 -0
  72. package/skills/bravos-report/style.md +44 -0
  73. package/skills/bravos-sast/SKILL.md +48 -0
  74. package/skills/bravos-schedule/SKILL.md +44 -0
  75. package/skills/bravos-schedule/loop.md +16 -0
  76. package/skills/bravos-ship/SKILL.md +58 -0
  77. package/skills/bravos-templatize/SKILL.md +48 -0
  78. package/skills/bravos-templatize/operator-guidance.md +20 -0
  79. package/templates/omp/bravos.js +62 -0
  80. package/templates/opencode/bravos.js +33 -0
  81. package/templates/report/report.html +88 -0
  82. package/templates/review/review.html +327 -0
  83. package/templates/workflows/bravos-nightly-claude.yml +173 -0
  84. package/templates/workflows/bravos-nightly-codex.yml +172 -0
  85. package/templates/workflows/bravos-pr-gate.yml +142 -0
@@ -0,0 +1,452 @@
1
+ // @comment -- "The licence: where it is found, whether it verifies, and what it unlocks. A port of the Python bravos CLI's licence.py onto the same claim contract (bugb-license+jwt, v1), so the three verifiers (this CLI, the portal, bugb-server) agree on every token: an Ed25519 JWS whose kid picks an embedded public key, strict RFC 8259 claims, product bugb, crit enforced, exp with a grace window of min(14 days, term/4), the tier ladder free < team < business < enterprise. Verification is offline and never throws: every input, however hostile, is an answer with a reason. Dependency-free (node:crypto only) because the session hooks read it on every start."
2
+ import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { join, sep } from 'node:path';
5
+
6
+ export const PRODUCT = 'bugb';
7
+ export const ALGORITHM = 'EdDSA';
8
+ export const SCHEMA_VERSIONS = [1];
9
+ export const REQUIRED_CRITICAL = ['product', 'schema_version'];
10
+ /** The claims this build enforces; a token whose crit names anything else is refused, never read. */
11
+ export const ENFORCED_CLAIMS = ['exp', 'nbf', 'product', 'schema_version', 'seats', 'sub', 'trial'];
12
+ const REQUIRED_CLAIMS = ['sub', 'exp', 'tier', 'seats', 'product'];
13
+ export const TIERS = ['free', 'team', 'business', 'enterprise'];
14
+ const PAID_TIERS = ['team', 'business', 'enterprise'];
15
+ export const GRACE_CAP_SECONDS = 14 * 86400;
16
+ export const GRACE_FRACTION = 4;
17
+ const MAX_TOKEN_BYTES = 8192;
18
+ const MAX_NESTING_DEPTH = 8;
19
+ const MAX_NUMBER_LITERAL = 32;
20
+ const SAFE_INTEGER = 2 ** 53 - 1;
21
+ const SIGNATURE_BYTES = 64;
22
+
23
+ export const LICENCE_ENV = 'BRAVOS_LICENCE';
24
+ export const LICENCE_ENV_ALT = 'BRAVOS_LICENSE';
25
+ export const LICENCE_FILES = ['licence.jwt', 'license.jwt', 'bravos-license.jwt'];
26
+ export const ACCOUNT_ORIGIN = 'https://account.bugb.io';
27
+ export const ACCOUNT_UPGRADE = `${ACCOUNT_ORIGIN}/pricing`;
28
+ export const ACCOUNT_LICENCES = `${ACCOUNT_ORIGIN}/dashboard/license`;
29
+
30
+ // @comment -- "The keys this build trusts, fixed in the source: the production licence key the portal signs under, and nothing else. A development key is recognised by name so a refusal can say what it saw, and is never trusted: its private half sits in developer checkouts. No environment variable, flag or file can add a key — a trust anchor chosen at run time is worth what whoever supplied it is worth, and that would be the party being gated."
31
+ export const PROD_KID = 'bugb-license-2026a';
32
+ export const DEV_KID = 'bravos-dev-2026-Q2-01';
33
+ export const TRUSTED_KEYS = Object.freeze({
34
+ [PROD_KID]: '-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAH2eaJpX3UCVKdoOMHZf1o8NUUKM5rOaUwoIy8WHQ38M=\n-----END PUBLIC KEY-----\n',
35
+ });
36
+ let trusted = TRUSTED_KEYS;
37
+ /** The key set verification uses by default: the trusted set, or what the test suite set in-process. */
38
+ export const trustedKeys = () => trusted;
39
+ // @comment -- "In-process only, for the test suite: there is no environment variable, flag or file behind this, so nothing outside the process can widen trust."
40
+ export const _setTrustedKeysForTests = (keys) => {
41
+ trusted = keys ? Object.freeze({ ...keys }) : TRUSTED_KEYS;
42
+ };
43
+
44
+ /** What this product gates on a Team (or higher) licence; everything else is free. */
45
+ export const PAID_FEATURES = ['pre-pr', 'ci', 'schedule', 'workspace', 'campaign', 'sast', 'codegraph'];
46
+ export const PAID_FEATURE_LABELS = {
47
+ 'pre-pr': 'the pre-PR gate',
48
+ ci: 'CI and nightly workflows',
49
+ schedule: 'scheduled upkeep',
50
+ workspace: 'multi-repository workspaces',
51
+ campaign: 'campaigns',
52
+ sast: 'SAST',
53
+ codegraph: 'the code graph',
54
+ };
55
+
56
+ // ---------------------------------------------------------------------------------------------
57
+ // Strict JSON: RFC 8259 and nothing more, so every verifier reads one value from one document.
58
+ // ---------------------------------------------------------------------------------------------
59
+
60
+ class Malformed extends Error {}
61
+ const malformed = (what, why) => new Malformed(`the ${what} ${why}`);
62
+
63
+ // @comment -- "A strict JSON parser for the two token segments: a repeated member name is refused (JSON.parse keeps the last and says nothing, which lets {\"product\":\"other\",\"product\":\"bugb\"} verify into a full entitlement), bare NaN/Infinity are not numbers, nesting is bounded, a number literal is bounded in length and magnitude, and a lone surrogate is not text. It produces values only; a bug in it can refuse a good document, never accept a bad one that JSON.parse would refuse."
64
+ function parseStrict(text, what) {
65
+ let i = 0;
66
+ const peek = () => text[i];
67
+ const ws = () => {
68
+ while (i < text.length && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r')) i++;
69
+ };
70
+ const fail = (why) => {
71
+ throw malformed(what, `is not RFC 8259 JSON: ${why} at offset ${i}`);
72
+ };
73
+ const value = (depth) => {
74
+ ws();
75
+ const c = peek();
76
+ if (c === undefined) fail('unexpected end');
77
+ if (c === '{') return object(depth + 1);
78
+ if (c === '[') return array(depth + 1);
79
+ if (c === '"') return string();
80
+ if (c === 't' && text.startsWith('true', i)) return (i += 4), true;
81
+ if (c === 'f' && text.startsWith('false', i)) return (i += 5), false;
82
+ if (c === 'n' && text.startsWith('null', i)) return (i += 4), null;
83
+ if (c === '-' || (c >= '0' && c <= '9')) return number();
84
+ return fail(`unexpected character ${JSON.stringify(c)}`);
85
+ };
86
+ const object = (depth) => {
87
+ if (depth > MAX_NESTING_DEPTH) throw malformed(what, `nests deeper than ${MAX_NESTING_DEPTH}`);
88
+ i++;
89
+ const out = {};
90
+ const seen = new Set();
91
+ ws();
92
+ if (peek() === '}') return i++, out;
93
+ for (;;) {
94
+ ws();
95
+ if (peek() !== '"') fail('expected a member name');
96
+ const name = string();
97
+ if (seen.has(name)) throw malformed(what, `is not RFC 8259 JSON: duplicate member ${JSON.stringify(name)}`);
98
+ seen.add(name);
99
+ ws();
100
+ if (peek() !== ':') fail('expected :');
101
+ i++;
102
+ out[name] = value(depth);
103
+ ws();
104
+ if (peek() === ',') {
105
+ i++;
106
+ continue;
107
+ }
108
+ if (peek() === '}') return i++, out;
109
+ fail('expected , or }');
110
+ }
111
+ };
112
+ const array = (depth) => {
113
+ if (depth > MAX_NESTING_DEPTH) throw malformed(what, `nests deeper than ${MAX_NESTING_DEPTH}`);
114
+ i++;
115
+ const out = [];
116
+ ws();
117
+ if (peek() === ']') return i++, out;
118
+ for (;;) {
119
+ out.push(value(depth));
120
+ ws();
121
+ if (peek() === ',') {
122
+ i++;
123
+ continue;
124
+ }
125
+ if (peek() === ']') return i++, out;
126
+ fail('expected , or ]');
127
+ }
128
+ };
129
+ const string = () => {
130
+ i++;
131
+ let out = '';
132
+ for (;;) {
133
+ const c = text[i];
134
+ if (c === undefined) fail('unterminated string');
135
+ if (c === '"') {
136
+ i++;
137
+ break;
138
+ }
139
+ if (c === '\\') {
140
+ const e = text[i + 1];
141
+ i += 2;
142
+ if (e === '"' || e === '\\' || e === '/') out += e;
143
+ else if (e === 'b') out += '\b';
144
+ else if (e === 'f') out += '\f';
145
+ else if (e === 'n') out += '\n';
146
+ else if (e === 'r') out += '\r';
147
+ else if (e === 't') out += '\t';
148
+ else if (e === 'u') {
149
+ const hex = text.slice(i, i + 4);
150
+ if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('bad \\u escape');
151
+ out += String.fromCharCode(parseInt(hex, 16));
152
+ i += 4;
153
+ } else fail('bad escape');
154
+ continue;
155
+ }
156
+ // eslint-disable-next-line no-control-regex
157
+ if (c < ' ') fail('control character in string');
158
+ out += c;
159
+ i++;
160
+ }
161
+ if (/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(out)) throw malformed(what, 'carries a string that is not well-formed UTF-8: lone surrogate');
162
+ return out;
163
+ };
164
+ const number = () => {
165
+ const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i));
166
+ if (!m) fail('bad number');
167
+ if (m[0].length > MAX_NUMBER_LITERAL) throw malformed(what, `carries a number literal longer than ${MAX_NUMBER_LITERAL} characters`);
168
+ i += m[0].length;
169
+ const n = Number(m[0]);
170
+ if (!(n >= -SAFE_INTEGER && n <= SAFE_INTEGER)) throw malformed(what, `carries a number outside ±${SAFE_INTEGER}, the range every implementation of this contract reads the same value from`);
171
+ return n;
172
+ };
173
+ const v = value(0);
174
+ ws();
175
+ if (i !== text.length) fail('trailing characters');
176
+ return v;
177
+ }
178
+
179
+ function b64url(segment, what) {
180
+ if (!segment) throw malformed(`${what} segment`, 'is empty');
181
+ if (!/^[A-Za-z0-9_-]+$/.test(segment)) throw malformed(`${what} segment`, 'is not unpadded base64url');
182
+ if (segment.length % 4 === 1) throw malformed(`${what} segment`, 'has an impossible length');
183
+ return Buffer.from(segment, 'base64url');
184
+ }
185
+
186
+ function jsonObject(bytes, what) {
187
+ let text;
188
+ try {
189
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
190
+ } catch {
191
+ throw malformed(what, 'is not valid UTF-8');
192
+ }
193
+ const parsed = parseStrict(text, what);
194
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw malformed(what, 'is not a JSON object');
195
+ return parsed;
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------------------------
199
+ // Verification
200
+ // ---------------------------------------------------------------------------------------------
201
+
202
+ class Invalid extends Error {
203
+ constructor(reason, message) {
204
+ super(message);
205
+ this.reason = reason;
206
+ }
207
+ }
208
+ const bad = (reason, message) => new Invalid(reason, message);
209
+
210
+ const text = (name, v) => {
211
+ if (typeof v !== 'string' || !v) throw bad('invalid_claims', `the licence's '${name}' claim must be a non-empty string`);
212
+ return v;
213
+ };
214
+ const integer = (name, v) => {
215
+ if (typeof v !== 'number' || !Number.isFinite(v)) throw bad('invalid_claims', `the licence's '${name}' claim must be an integer`);
216
+ if (!Number.isInteger(v)) throw bad('invalid_claims', `the licence's '${name}' claim must be a whole number`);
217
+ return v;
218
+ };
219
+ const count = (name, v) => {
220
+ const n = integer(name, v);
221
+ if (n < 0) throw bad('invalid_claims', `the licence's '${name}' claim must not be negative`);
222
+ return n;
223
+ };
224
+ const moment = (name, v) => {
225
+ if (typeof v !== 'number' || !Number.isFinite(v)) throw bad('invalid_claims', `the licence's '${name}' claim must be seconds since the epoch`);
226
+ return v;
227
+ };
228
+ const features = (v) => {
229
+ if (v === undefined || v === null) return [];
230
+ if (!Array.isArray(v)) throw bad('invalid_claims', "the licence's 'features' claim must be a list");
231
+ for (const f of v) if (typeof f !== 'string') throw bad('invalid_claims', "the licence's 'features' claim must contain only strings");
232
+ return [...new Set(v)].sort();
233
+ };
234
+
235
+ export function graceWindowSeconds(exp, termStart, cap = GRACE_CAP_SECONDS) {
236
+ if (!(cap >= 0)) throw new Error('cap must not be negative');
237
+ if (termStart === null || termStart === undefined) return cap;
238
+ const window = Math.floor(Math.floor(exp - termStart) / GRACE_FRACTION);
239
+ if (window < 0) return 0;
240
+ return Math.min(cap, window);
241
+ }
242
+
243
+ export const licenceState = (exp, now, grace) => (now < exp ? 'valid' : now < exp + grace ? 'grace' : 'expired');
244
+
245
+ function checkCritical(claims) {
246
+ const crit = claims.crit;
247
+ if (crit === undefined || crit === null) throw bad('invalid_claims', 'the licence is signed but does not say what a licence must say: missing required claim(s) crit');
248
+ if (!Array.isArray(crit)) throw bad('invalid_claims', "the licence's 'crit' must be a list of claim names");
249
+ if (crit.length === 0) throw bad('invalid_claims', "the licence's 'crit' must not be empty");
250
+ for (const n of crit) if (typeof n !== 'string' || !n) throw bad('invalid_claims', "the licence's 'crit' must contain only non-empty strings");
251
+ const declared = new Set(crit);
252
+ if (declared.size !== crit.length) throw bad('invalid_claims', "the licence's 'crit' must not repeat a claim name");
253
+ if (declared.has('crit')) throw bad('invalid_claims', "the licence's 'crit' must not name itself");
254
+ const undeclared = REQUIRED_CRITICAL.filter((n) => !declared.has(n));
255
+ if (undeclared.length) throw bad('invalid_claims', `the licence's 'crit' must declare ${undeclared.join(', ')} critical; a licence that does not is not a v1 licence whatever its schema_version says`);
256
+ const unenforced = [...declared].filter((n) => !ENFORCED_CLAIMS.includes(n)).sort();
257
+ if (unenforced.length) throw bad('unsupported_claim', `the licence requires this build to enforce something it does not implement, so accepting it would grant more than the licence says: ${unenforced.join(', ')}. Upgrade bravos`);
258
+ const absent = [...declared].filter((n) => claims[n] === undefined || claims[n] === null).sort();
259
+ if (absent.length) throw bad('invalid_claims', `the licence is signed but does not say what a licence must say: critical claim(s) declared but absent: ${absent.join(', ')}`);
260
+ }
261
+
262
+ function trialOf(claims, issuedAt, exp) {
263
+ if (!('trial' in claims)) return null;
264
+ const raw = claims.trial;
265
+ const keys = raw && typeof raw === 'object' && !Array.isArray(raw) ? Object.keys(raw).sort() : null;
266
+ if (!keys || keys.join(',') !== 'ends_at,started_at' || !claims.crit.includes('trial')) throw bad('invalid_claims', 'The trial licence is incomplete. Download it again.');
267
+ const start = integer('trial.started_at', raw.started_at);
268
+ const end = integer('trial.ends_at', raw.ends_at);
269
+ if (start < 0 || end < 0 || issuedAt === null || !(start <= issuedAt && issuedAt < end) || end !== exp) throw bad('invalid_claims', 'The trial licence dates are invalid.');
270
+ return { startedAt: start, endsAt: end };
271
+ }
272
+
273
+ function entitle(claims, now, graceCap, product = PRODUCT, schemaVersions = SCHEMA_VERSIONS) {
274
+ const sv = claims.schema_version;
275
+ if (sv === undefined || sv === null) throw bad('invalid_claims', 'the licence is signed but does not say what a licence must say: missing required claim(s) schema_version');
276
+ const version = integer('schema_version', sv);
277
+ if (!schemaVersions.includes(version)) throw bad('unsupported_schema', `the licence is a newer kind of licence than this build reads, so this build would be guessing at what it grants (schema version ${version}; this build implements ${schemaVersions.join(', ')}). Upgrade bravos`);
278
+ checkCritical(claims);
279
+ const missing = REQUIRED_CLAIMS.filter((n) => claims[n] === undefined || claims[n] === null);
280
+ if (missing.length) throw bad('invalid_claims', `the licence is signed but does not say what a licence must say: missing required claim(s) ${missing.join(', ')}`);
281
+ const tenant = text('sub', claims.sub);
282
+ const scopedTo = text('product', claims.product);
283
+ const tier = text('tier', claims.tier);
284
+ const seats = count('seats', claims.seats);
285
+ const exp = moment('exp', claims.exp);
286
+ const feats = features(claims.features);
287
+ const repoLimit = claims.repo_limit === undefined || claims.repo_limit === null ? null : count('repo_limit', claims.repo_limit);
288
+ const iat = claims.iat === undefined || claims.iat === null ? null : moment('iat', claims.iat);
289
+ const nbf = claims.nbf === undefined || claims.nbf === null ? null : moment('nbf', claims.nbf);
290
+ const jti = claims.jti === undefined || claims.jti === null ? null : text('jti', claims.jti);
291
+ const iss = claims.iss === undefined || claims.iss === null ? null : text('iss', claims.iss);
292
+ const trial = trialOf(claims, iat, exp);
293
+ if (trial) {
294
+ // A trial is one person on the Team bundle for its term; the Python CLI additionally pinned features to ["sast"], which this product does not.
295
+ if (tier !== 'team' || seats !== 1) throw bad('invalid_claims', 'This trial licence has invalid permissions.');
296
+ if (now < trial.startedAt) throw bad('not_yet_valid', 'Your trial has not started yet.');
297
+ }
298
+ if (scopedTo !== product) throw bad('product_mismatch', `the licence is for a different product than this build ('${scopedTo}', not '${product}')`);
299
+ if (nbf !== null && now < nbf) throw bad('not_yet_valid', `the licence term has not started yet — it starts at ${new Date(nbf * 1000).toISOString()}; check this machine's clock if it should have`);
300
+ const grace = trial ? 0 : graceWindowSeconds(exp, nbf !== null ? nbf : iat, graceCap);
301
+ return {
302
+ state: licenceState(exp, now, grace),
303
+ reason: null,
304
+ message: null,
305
+ tenant,
306
+ product: scopedTo,
307
+ schemaVersion: version,
308
+ tier,
309
+ seats,
310
+ features: feats,
311
+ repoLimit,
312
+ expiresAt: exp,
313
+ graceUntil: exp + grace,
314
+ issuedAt: iat,
315
+ notBefore: nbf,
316
+ licenceId: jti,
317
+ issuer: iss,
318
+ trial,
319
+ };
320
+ }
321
+
322
+ // @comment -- "Verifies one token at one instant against one key set and answers a status, never throwing: state valid|grace|expired for a licence this build accepts, or invalid with a reason code (malformed, unsupported_alg, unknown_key, bad_signature, invalid_claims, not_yet_valid, unsupported_schema, unsupported_claim, product_mismatch). `keys` maps kid to a PEM public key and defaults to the trusted set; tests pass their own."
323
+ export function verifyAt(token, now, keys = trustedKeys(), graceCap = GRACE_CAP_SECONDS, opts = {}) {
324
+ // The product and schema versions this verifier enforces; the contract's conformance vectors run one verifier per product.
325
+ const product = opts.product ?? PRODUCT;
326
+ const schemaVersions = opts.schemaVersions ?? SCHEMA_VERSIONS;
327
+ try {
328
+ if (typeof token !== 'string') throw malformed('token', `is a string, got ${typeof token}`);
329
+ if (Buffer.byteLength(token, 'utf8') > MAX_TOKEN_BYTES) throw malformed('token', `is at most ${MAX_TOKEN_BYTES} bytes, got ${Buffer.byteLength(token, 'utf8')}`);
330
+ // eslint-disable-next-line no-control-regex
331
+ if (/[^\x00-\x7f]/.test(token)) throw malformed('token', 'is a single ASCII word; this one carries a non-ASCII byte');
332
+ const parts = token.split('.');
333
+ if (parts.length !== 3) throw malformed('token', `is 3 dot-separated segments, got ${parts.length}`);
334
+ const [h, c, s] = parts;
335
+ const header = jsonObject(b64url(h, 'header'), 'header');
336
+ if (header.alg !== ALGORITHM) throw bad('unsupported_alg', `the licence names a signing algorithm this build does not verify (${JSON.stringify(header.alg)}, not '${ALGORITHM}'), so its signature proves nothing`);
337
+ const kid = header.kid;
338
+ // A keyring selects by kid (what a shipped build holds, so a rotated key reads as unknown_key, not forged).
339
+ // A single key, written as the '*' entry, is tried whatever the kid says — the contract's single-key verifier (§8.2), used by the shared vectors.
340
+ const pem = keys['*'] ?? (typeof kid === 'string' ? keys[kid] : undefined);
341
+ if (!pem) {
342
+ const detail = Object.keys(keys).length === 0 ? 'no verification keys are configured' : typeof kid !== 'string' ? 'the licence header names no kid, so there is no key to check it with' : kid === DEV_KID ? `${kid} is the development key, which a released bravos does not trust` : `no verification key for kid '${kid}'`;
343
+ throw bad('unknown_key', `the licence was signed with a key this build does not trust (${detail}). A rotated key is restored by upgrading bravos, not by editing anything here`);
344
+ }
345
+ const sig = b64url(s, 'signature');
346
+ if (sig.length !== SIGNATURE_BYTES) throw bad('bad_signature', `an Ed25519 signature is ${SIGNATURE_BYTES} bytes, got ${sig.length}, so the licence signature does not verify`);
347
+ let ok = false;
348
+ try {
349
+ ok = cryptoVerify(null, Buffer.from(`${h}.${c}`, 'ascii'), createPublicKey({ key: pem, format: 'pem' }), sig);
350
+ } catch {
351
+ ok = false;
352
+ }
353
+ if (!ok) throw bad('bad_signature', 'the licence signature does not verify — it has been altered, or it was not issued by Bugb');
354
+ const claims = jsonObject(b64url(c, 'claims'), 'claims');
355
+ return { ...entitle(claims, now, graceCap, product, schemaVersions), kid };
356
+ } catch (e) {
357
+ if (e instanceof Malformed) return invalid('malformed', e.message);
358
+ if (e instanceof Invalid) return invalid(e.reason, e.message);
359
+ return invalid('malformed', `the token could not be read: ${e && e.message ? e.message : String(e)}`);
360
+ }
361
+ }
362
+
363
+ const invalid = (reason, message) => ({ state: 'invalid', reason, message, tenant: null, product: null, schemaVersion: null, tier: null, seats: null, features: [], repoLimit: null, expiresAt: null, graceUntil: null, issuedAt: null, notBefore: null, licenceId: null, issuer: null, trial: null, kid: null });
364
+
365
+ export const verify = (token, keys) => verifyAt(token, Math.floor(Date.now() / 1000), keys);
366
+
367
+ // ---------------------------------------------------------------------------------------------
368
+ // Where the licence is
369
+ // ---------------------------------------------------------------------------------------------
370
+
371
+ const readText = (file) => {
372
+ try {
373
+ const t = readFileSync(file, 'utf8').trim();
374
+ return t || null;
375
+ } catch {
376
+ return null;
377
+ }
378
+ };
379
+
380
+ export const bravosHome = (env = process.env) => env.BRAVOS_HOME || (env.HOME ? join(env.HOME, '.bravos') : null);
381
+
382
+ // @comment -- "Finds the licence: $BRAVOS_LICENCE or $BRAVOS_LICENSE first (a path when it contains a separator or starts with ~, else the token itself), then licence.jwt, license.jwt or bravos-license.jwt under $BRAVOS_HOME, ~/.bravos and the pre-rename ~/.bugb. The environment wins because it is the narrower statement. Returns {token, source, reason}: reason 'unreadable' when the variable names a file that cannot be read, 'absent' when nothing is found."
383
+ export function find(env = process.env) {
384
+ for (const name of [LICENCE_ENV, LICENCE_ENV_ALT]) {
385
+ const raw = (env[name] || '').trim();
386
+ if (!raw) continue;
387
+ if (raw.includes(sep) || raw.includes('/') || raw.startsWith('~')) {
388
+ const file = raw.startsWith('~') && env.HOME ? join(env.HOME, raw.slice(1)) : raw;
389
+ const t = readText(file);
390
+ if (t === null) return { token: null, source: `$${name} -> ${file}`, reason: 'unreadable' };
391
+ return { token: t, source: `$${name} -> ${file}`, reason: null };
392
+ }
393
+ return { token: raw, source: `$${name}`, reason: null };
394
+ }
395
+ const homes = [env.BRAVOS_HOME, env.HOME && join(env.HOME, '.bravos'), env.HOME && join(env.HOME, '.bugb')].filter(Boolean);
396
+ for (const home of homes) {
397
+ for (const f of LICENCE_FILES) {
398
+ const t = readText(join(home, f));
399
+ if (t !== null) return { token: t, source: join(home, f), reason: null };
400
+ }
401
+ }
402
+ return { token: null, source: `$${LICENCE_ENV} and ${join(bravosHome(env) || '~/.bravos', LICENCE_FILES[0])}`, reason: 'absent' };
403
+ }
404
+
405
+ // @comment -- "The licence status this session runs under: find + verify at now, plus what is unlocked. `absent` and `unreadable` are states of their own, so a briefing can say 'no licence' rather than 'invalid'."
406
+ export function status(env = process.env, now = Math.floor(Date.now() / 1000), keys = trustedKeys()) {
407
+ const found = find(env);
408
+ if (!found.token) {
409
+ return { ...invalid(found.reason, found.reason === 'unreadable' ? `${found.source} could not be read` : `no licence at ${found.source}`), state: found.reason, source: found.source, licensed: false, unlocked: [], locked: [...PAID_FEATURES] };
410
+ }
411
+ const v = verifyAt(found.token, now, keys);
412
+ const licensed = isLicensed(v);
413
+ return { ...v, source: found.source, licensed, unlocked: licensed ? [...PAID_FEATURES] : [], locked: licensed ? [] : [...PAID_FEATURES] };
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------------------------
417
+ // What it unlocks
418
+ // ---------------------------------------------------------------------------------------------
419
+
420
+ export const tierRank = (tier) => TIERS.indexOf(tier);
421
+
422
+ /** Whether a verified status unlocks the paid bundle: a paid tier, and not expired (grace still runs, and says so). */
423
+ export const isLicensed = (v) => (v.state === 'valid' || v.state === 'grace') && PAID_TIERS.includes(v.tier);
424
+
425
+ // @comment -- "The one entitlement question every gate asks. v1 gates on the tier alone; the features claim is carried and shown, not enforced, until a per-feature add-on exists. An unknown feature name is free (a gate that misspells its own name must fail open into the free behaviour, not lock a paying customer out of something nobody sells)."
426
+ export const entitled = (v, feature) => !PAID_FEATURES.includes(feature) || isLicensed(v);
427
+
428
+ const days = (seconds) => Math.max(0, Math.ceil(seconds / 86400));
429
+ const date = (s) => new Date(s * 1000).toISOString().slice(0, 10);
430
+
431
+ // @comment -- "The one-line plan statement the briefing, doctor, next and watch all print, so a person always knows which tier they are on and what would change it."
432
+ export function planLine(v, now = Math.floor(Date.now() / 1000)) {
433
+ const locked = PAID_FEATURES.map((f) => PAID_FEATURE_LABELS[f]).join(', ');
434
+ const activate = `bravos account activate starts a 30-day Team trial, no card`;
435
+ if (v.state === 'absent') return `Plan: Free (researcher) — ${locked} are on Team · ${activate}`;
436
+ if (v.state === 'unreadable') return `Plan: Free (researcher) — the licence at ${v.source} could not be read · ${activate}`;
437
+ if (v.state === 'invalid') return `Plan: Free (researcher) — the licence at ${v.source} is not accepted (${v.reason}): ${v.message} · ${ACCOUNT_LICENCES}`;
438
+ const tierName = v.tier.charAt(0).toUpperCase() + v.tier.slice(1);
439
+ if (v.state === 'expired') return `Plan: ${tierName}, expired ${date(v.expiresAt)} — ${locked} are locked until it is renewed · ${ACCOUNT_LICENCES}`;
440
+ if (!PAID_TIERS.includes(v.tier)) return `Plan: Free (researcher, signed in) — ${locked} are on Team · ${ACCOUNT_UPGRADE}`;
441
+ if (v.trial) return `Plan: Team (trial, ${days(v.trial.endsAt - now)} days left) — everything is unlocked until ${date(v.trial.endsAt)} · ${ACCOUNT_UPGRADE} to keep it`;
442
+ if (v.state === 'grace') return `Plan: ${tierName} · ${v.seats} seat${v.seats === 1 ? '' : 's'} · expired ${date(v.expiresAt)}, in grace until ${date(v.graceUntil)} — renew at ${ACCOUNT_LICENCES}`;
443
+ return `Plan: ${tierName} · ${v.seats} seat${v.seats === 1 ? '' : 's'} · renews ${date(v.expiresAt)}`;
444
+ }
445
+
446
+ // @comment -- "What a gate says when a paid feature is asked for without the licence for it: what the feature does, that it is on the Team plan, and the two ways to get it. Never silent, never a stack trace."
447
+ export function refusal(v, feature, what) {
448
+ const label = PAID_FEATURE_LABELS[feature] ?? feature;
449
+ const lead = what ? `${what} ` : `${label.charAt(0).toUpperCase() + label.slice(1)} `;
450
+ const tail = v.state === 'expired' ? `your ${v.tier} licence expired ${date(v.expiresAt)}; renew at ${ACCOUNT_LICENCES}` : v.state === 'invalid' || v.state === 'unreadable' ? `the licence at ${v.source} is not accepted (${v.reason}); ${ACCOUNT_LICENCES}` : `it is on the Team plan: bravos account activate starts a 30-day trial (no card), or ${ACCOUNT_UPGRADE}`;
451
+ return `${lead}needs a licence — ${tail}`;
452
+ }