@subvalue/cli 0.1.3 → 0.1.4
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/PRE_RELEASE.md +6 -6
- package/README.md +5 -3
- package/dist/PRICING.md +15 -16
- package/dist/packages/cli/src/index.js +109 -20
- package/dist/packages/cli/src/protocol.js +13 -0
- package/dist/packages/cli/src/runtime.js +44 -14
- package/dist/packages/cli/src/server.js +306 -62
- package/dist/packages/core/src/calendar.js +41 -18
- package/dist/packages/core/src/coverage.js +12 -0
- package/dist/packages/core/src/demo.js +61 -9
- package/dist/packages/core/src/metadata.js +52 -9
- package/dist/packages/core/src/pricing/current.js +346 -297
- package/dist/packages/core/src/pricing/index.js +373 -73
- package/dist/packages/core/src/providers/claude/index.js +93 -21
- package/dist/packages/core/src/providers/codex/index.js +213 -46
- package/dist/packages/core/src/scanner.js +226 -46
- package/dist/packages/core/src/security.js +127 -34
- package/dist/packages/core/src/storage.js +215 -47
- package/dist/packages/core/src/subscriptions.js +146 -21
- package/dist/packages/core/src/summary.js +408 -59
- package/dist/packages/core/src/types.js +71 -17
- package/dist/packages/core/src/usage-json.js +166 -47
- package/dist/packages/receipt/src/index.js +386 -82
- package/dist/packages/ui/src/brand.js +42 -27
- package/dist/public/app.css +2280 -61
- package/dist/public/apps/web/src/landing.js +200 -8
- package/dist/public/apps/web/src/main.js +1143 -98
- package/dist/public/index.html +24 -1
- package/dist/public/landing.css +3235 -80
- package/dist/public/landing.html +21 -1
- package/dist/public/packages/core/src/calendar.js +41 -18
- package/dist/public/packages/core/src/coverage.js +12 -0
- package/dist/public/packages/core/src/subscriptions.js +146 -21
- package/dist/public/packages/receipt/src/index.js +386 -82
- package/dist/public/packages/ui/src/brand.js +42 -27
- package/dist/public/privacy.css +197 -9
- package/package.json +1 -1
|
@@ -1,76 +1,320 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {randomBytes,timingSafeEqual} from 'node:crypto';
|
|
5
|
-
|
|
6
|
-
import {scan,
|
|
7
|
-
import {dateRange,summarize} from '../../core/src/summary.js';
|
|
8
|
-
import {demoData} from '../../core/src/demo.js';
|
|
9
|
-
|
|
4
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
import { scan, } from '../../core/src/scanner.js';
|
|
7
|
+
import { dateRange, summarize } from '../../core/src/summary.js';
|
|
8
|
+
import { demoData } from '../../core/src/demo.js';
|
|
9
|
+
import {
|
|
10
|
+
defaultSettings,
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
} from '../../core/src/types.js';
|
|
10
15
|
|
|
11
16
|
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
import { isRecord } from '../../core/src/metadata.js';
|
|
18
|
+
import { safeDirectory } from '../../core/src/security.js';
|
|
19
|
+
import { subscriptionPlan } from '../../core/src/subscriptions.js';
|
|
20
|
+
const mime = {
|
|
21
|
+
'.html': 'text/html; charset=utf-8',
|
|
22
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
23
|
+
'.css': 'text/css; charset=utf-8',
|
|
24
|
+
'.svg': 'image/svg+xml',
|
|
25
|
+
'.woff2': 'font/woff2',
|
|
26
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
27
|
+
};
|
|
28
|
+
const billingModes = ['UNKNOWN', 'SUBSCRIPTION', 'API', 'MIXED'] ;
|
|
29
|
+
const isBillingMode = (value ) =>
|
|
30
|
+
billingModes.some((mode) => mode === value);
|
|
31
|
+
|
|
32
|
+
export function validateSettings(value ) {
|
|
33
|
+
if (!isRecord(value) || value.currency !== 'USD' || typeof value.onboarded !== 'boolean')
|
|
34
|
+
throw new Error('Invalid settings');
|
|
35
|
+
if (!isRecord(value.billing) || !isRecord(value.include))
|
|
36
|
+
throw new Error('Invalid billing settings');
|
|
37
|
+
const settings = defaultSettings();
|
|
38
|
+
settings.onboarded = value.onboarded;
|
|
39
|
+
if (value.receiptTheme !== undefined) {
|
|
40
|
+
if (value.receiptTheme !== 'dark' && value.receiptTheme !== 'light')
|
|
41
|
+
throw new Error('Invalid receipt theme');
|
|
42
|
+
settings.receiptTheme = value.receiptTheme;
|
|
43
|
+
}
|
|
44
|
+
for (const provider of ['codex', 'claude'] ) {
|
|
45
|
+
const billing = value.billing[provider];
|
|
46
|
+
const included = value.include[provider];
|
|
47
|
+
if (!isRecord(billing) || !isBillingMode(billing.mode) || typeof included !== 'boolean')
|
|
48
|
+
throw new Error('Invalid billing settings');
|
|
49
|
+
const monthly = billing.monthly;
|
|
50
|
+
if (
|
|
51
|
+
monthly !== null &&
|
|
52
|
+
(typeof monthly !== 'number' || !Number.isFinite(monthly) || monthly < 0 || monthly > 1000000)
|
|
53
|
+
)
|
|
54
|
+
throw new Error('Invalid subscription amount');
|
|
55
|
+
const planId = billing.planId;
|
|
56
|
+
if (planId != null && typeof planId !== 'string') throw new Error('Invalid subscription plan');
|
|
57
|
+
const plan = planId == null ? undefined : subscriptionPlan(provider, planId);
|
|
58
|
+
if (planId != null && !plan) throw new Error('Invalid subscription plan');
|
|
59
|
+
if (
|
|
60
|
+
billing.mode === 'SUBSCRIPTION' &&
|
|
61
|
+
plan &&
|
|
62
|
+
(monthly === null || Math.abs(monthly - plan.monthly) > 0.005)
|
|
63
|
+
)
|
|
64
|
+
throw new Error('Subscription amount does not match the selected plan');
|
|
65
|
+
settings.billing[provider] = {
|
|
66
|
+
mode: billing.mode,
|
|
67
|
+
monthly: billing.mode === 'SUBSCRIPTION' ? (plan?.monthly ?? monthly) : null,
|
|
68
|
+
};
|
|
69
|
+
if (billing.mode === 'SUBSCRIPTION' && planId !== undefined)
|
|
70
|
+
settings.billing[provider].planId = plan?.id ?? null;
|
|
71
|
+
settings.include[provider] = included;
|
|
72
|
+
}
|
|
73
|
+
return settings;
|
|
23
74
|
}
|
|
24
|
-
export async function startServer(options
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
75
|
+
export async function startServer(options
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
) {
|
|
82
|
+
const secret = randomBytes(32).toString('hex');
|
|
83
|
+
const demo = options.demo ? demoData() : null;
|
|
84
|
+
let demoSettings = demo?.settings;
|
|
85
|
+
let scanning = false;
|
|
86
|
+
let progress = null;
|
|
87
|
+
let sessionOrigin = '';
|
|
88
|
+
let scanCompletion = null;
|
|
89
|
+
let revision = '',
|
|
90
|
+
history = null;
|
|
91
|
+
const summaries = new Map ();
|
|
92
|
+
const invalidate = () => {
|
|
93
|
+
revision = '';
|
|
94
|
+
history = null;
|
|
95
|
+
summaries.clear();
|
|
96
|
+
};
|
|
97
|
+
const server = http.createServer(async (req, res) => {
|
|
98
|
+
const headers = {
|
|
99
|
+
'Cache-Control': 'no-store',
|
|
100
|
+
'X-Content-Type-Options': 'nosniff',
|
|
101
|
+
'Referrer-Policy': 'no-referrer',
|
|
102
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
103
|
+
'X-Frame-Options': 'DENY',
|
|
104
|
+
'Content-Security-Policy':
|
|
105
|
+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
106
|
+
};
|
|
107
|
+
const json = (status , data ) => {
|
|
108
|
+
res.writeHead(status, { ...headers, 'Content-Type': 'application/json' });
|
|
109
|
+
res.end(JSON.stringify(data));
|
|
110
|
+
};
|
|
111
|
+
const expectedHost = new URL(sessionOrigin).host;
|
|
112
|
+
if (req.headers.host !== expectedHost) {
|
|
113
|
+
json(403, { error: 'Host denied' });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (req.headers.origin && req.headers.origin !== sessionOrigin) {
|
|
117
|
+
json(403, { error: 'Origin denied' });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') {
|
|
121
|
+
json(403, { error: 'Cross-site access denied' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const url = new URL(req.url ?? '/', sessionOrigin);
|
|
125
|
+
try {
|
|
126
|
+
if (url.pathname.startsWith('/api/')) {
|
|
38
127
|
// The bootstrap is same-origin only. Mutations require its per-launch token.
|
|
39
|
-
if(url.pathname==='/api/bootstrap'&&req.method==='GET'){
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
128
|
+
if (url.pathname === '/api/bootstrap' && req.method === 'GET') {
|
|
129
|
+
json(200, {
|
|
130
|
+
token: secret,
|
|
131
|
+
settings: demoSettings ?? options.store .settings(),
|
|
132
|
+
sources: demo?.statuses ?? options.store .statuses(),
|
|
133
|
+
demo: !!demo,
|
|
134
|
+
lastScan: options.store?.get ('lastScan') ?? null,
|
|
135
|
+
scanning,
|
|
136
|
+
progress,
|
|
137
|
+
} );
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (url.pathname === '/api/summary' && req.method === 'GET') {
|
|
141
|
+
if (scanning) {
|
|
142
|
+
json(503, { error: 'Scan in progress' });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const range = dateRange(
|
|
146
|
+
url.searchParams.get('period') ?? '30d',
|
|
147
|
+
new Date(),
|
|
148
|
+
url.searchParams.get('from') ?? undefined,
|
|
149
|
+
url.searchParams.get('to') ?? undefined,
|
|
150
|
+
url.searchParams.get('month') ?? undefined,
|
|
151
|
+
url.searchParams.get('anchor') ?? undefined,
|
|
152
|
+
);
|
|
43
153
|
// History bounds use all normalized metadata, never original transcripts.
|
|
44
|
-
const currentRevision=demo?'demo':options.store .revision();
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
154
|
+
const currentRevision = demo ? 'demo' : options.store .revision();
|
|
155
|
+
if (currentRevision !== revision) {
|
|
156
|
+
invalidate();
|
|
157
|
+
revision = currentRevision;
|
|
158
|
+
}
|
|
159
|
+
const key = JSON.stringify(range);
|
|
160
|
+
let result = summaries.get(key);
|
|
161
|
+
if (!result) {
|
|
162
|
+
history ??= demo?.records ?? options.store .records();
|
|
163
|
+
result = summarize(
|
|
164
|
+
history,
|
|
165
|
+
demo?.statuses ?? options.store .statuses(),
|
|
166
|
+
demoSettings ?? options.store .settings(),
|
|
167
|
+
range,
|
|
168
|
+
demo?.catalog,
|
|
169
|
+
!!demo,
|
|
170
|
+
);
|
|
171
|
+
if (summaries.size >= 16) summaries.delete(summaries.keys().next().value );
|
|
172
|
+
summaries.set(key, result);
|
|
173
|
+
}
|
|
174
|
+
json(200, result);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const given = req.headers['x-subvalue-token'];
|
|
178
|
+
if (
|
|
179
|
+
typeof given !== 'string' ||
|
|
180
|
+
given.length !== secret.length ||
|
|
181
|
+
!timingSafeEqual(Buffer.from(given), Buffer.from(secret))
|
|
182
|
+
) {
|
|
183
|
+
json(403, { error: 'Session token required' });
|
|
184
|
+
return;
|
|
48
185
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
186
|
+
if (url.pathname === '/api/settings' && req.method === 'POST') {
|
|
187
|
+
let body = '';
|
|
188
|
+
for await (const chunk of req) {
|
|
189
|
+
body += chunk;
|
|
190
|
+
if (body.length > 16384) {
|
|
191
|
+
json(413, { error: 'Request too large' });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const settings = validateSettings(JSON.parse(body));
|
|
196
|
+
// A rescan holds a transaction on this connection while streaming files.
|
|
197
|
+
// Save independently after it ends, so a later rollback cannot erase
|
|
198
|
+
// settings that the browser has already been told were persisted.
|
|
199
|
+
while (scanCompletion) await scanCompletion;
|
|
200
|
+
if (demo) demoSettings = settings;
|
|
201
|
+
else options.store .saveSettings(settings);
|
|
202
|
+
invalidate();
|
|
203
|
+
json(200, { saved: true });
|
|
204
|
+
return;
|
|
53
205
|
}
|
|
54
|
-
if(url.pathname==='/api/rescan'&&req.method==='POST'){
|
|
55
|
-
if(demo)
|
|
56
|
-
|
|
206
|
+
if (url.pathname === '/api/rescan' && req.method === 'POST') {
|
|
207
|
+
if (demo) {
|
|
208
|
+
json(200, { scanning: false });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (scanning) {
|
|
212
|
+
json(409, { error: 'Scan already running' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
scanning = true;
|
|
216
|
+
json(202, { scanning: true });
|
|
217
|
+
scanCompletion = scan(options.store , {
|
|
218
|
+
home: options.home,
|
|
219
|
+
onProgress: (p) => {
|
|
220
|
+
progress = p;
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
.then(() => {})
|
|
224
|
+
.catch(() => {
|
|
225
|
+
progress = null;
|
|
226
|
+
})
|
|
227
|
+
.finally(() => {
|
|
228
|
+
invalidate();
|
|
229
|
+
scanning = false;
|
|
230
|
+
scanCompletion = null;
|
|
231
|
+
});
|
|
232
|
+
return;
|
|
57
233
|
}
|
|
58
|
-
json(404,{error:'Not found'});
|
|
234
|
+
json(404, { error: 'Not found' });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
238
|
+
json(405, { error: 'Method not allowed' });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const name =
|
|
242
|
+
url.pathname === '/' || ['/overview', '/receipt', '/settings'].includes(url.pathname)
|
|
243
|
+
? 'index.html'
|
|
244
|
+
: url.pathname === '/landing'
|
|
245
|
+
? 'landing.html'
|
|
246
|
+
: url.pathname.slice(1);
|
|
247
|
+
if (!/^[a-zA-Z0-9_./-]+$/.test(name) || name.includes('..') || !mime[path.extname(name)]) {
|
|
248
|
+
json(404, { error: 'Not found' });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const base = path.resolve(options.publicDirectory),
|
|
252
|
+
target = path.resolve(base, name);
|
|
253
|
+
if (
|
|
254
|
+
!target.startsWith(base + path.sep) ||
|
|
255
|
+
!safeDirectory(path.dirname(target)) ||
|
|
256
|
+
!fs.existsSync(target)
|
|
257
|
+
) {
|
|
258
|
+
json(404, { error: 'Not found' });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const before = fs.lstatSync(target);
|
|
262
|
+
if (
|
|
263
|
+
!before.isFile() ||
|
|
264
|
+
before.isSymbolicLink() ||
|
|
265
|
+
!fs.realpathSync(target).startsWith(fs.realpathSync(base) + path.sep)
|
|
266
|
+
) {
|
|
267
|
+
json(404, { error: 'Not found' });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (req.method === 'HEAD') {
|
|
271
|
+
res.writeHead(200, { ...headers, 'Content-Type': mime[path.extname(target)] });
|
|
272
|
+
res.end();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const fd = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)),
|
|
276
|
+
after = fs.fstatSync(fd);
|
|
277
|
+
if (before.ino !== after.ino || before.dev !== after.dev) {
|
|
278
|
+
fs.closeSync(fd);
|
|
279
|
+
json(404, { error: 'Not found' });
|
|
280
|
+
return;
|
|
59
281
|
}
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if(before.ino!==after.ino||before.dev!==after.dev){fs.closeSync(fd);json(404,{error:'Not found'});return;}
|
|
69
|
-
res.writeHead(200,{...headers,'Content-Type':mime[path.extname(target)]});
|
|
70
|
-
const stream=fs.createReadStream(target,{fd,autoClose:true});stream.on('error',()=>res.destroy());res.once('close',()=>stream.destroy());stream.pipe(res);
|
|
71
|
-
}catch{json(400,{error:'Unable to complete request'});}
|
|
282
|
+
res.writeHead(200, { ...headers, 'Content-Type': mime[path.extname(target)] });
|
|
283
|
+
const stream = fs.createReadStream(target, { fd, autoClose: true });
|
|
284
|
+
stream.on('error', () => res.destroy());
|
|
285
|
+
res.once('close', () => stream.destroy());
|
|
286
|
+
stream.pipe(res);
|
|
287
|
+
} catch {
|
|
288
|
+
json(400, { error: 'Unable to complete request' });
|
|
289
|
+
}
|
|
72
290
|
});
|
|
73
|
-
const listen=(port
|
|
74
|
-
|
|
75
|
-
|
|
291
|
+
const listen = (port ) =>
|
|
292
|
+
new Promise ((resolve, reject) => {
|
|
293
|
+
const error = (e ) => reject(e);
|
|
294
|
+
server.once('error', error);
|
|
295
|
+
server.listen(port, '127.0.0.1', () => {
|
|
296
|
+
server.removeListener('error', error);
|
|
297
|
+
const address = server.address();
|
|
298
|
+
if (!address || typeof address === 'string')
|
|
299
|
+
return reject(new Error('No listening address'));
|
|
300
|
+
sessionOrigin = `http://127.0.0.1:${address.port}`;
|
|
301
|
+
resolve();
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
try {
|
|
305
|
+
await listen(options.port);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
if (
|
|
308
|
+
options.port === 0 ||
|
|
309
|
+
!['EADDRINUSE', 'EACCES'].includes((e ).code ?? '')
|
|
310
|
+
)
|
|
311
|
+
throw e;
|
|
312
|
+
await listen(0);
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
server,
|
|
316
|
+
url: sessionOrigin,
|
|
317
|
+
close: () =>
|
|
318
|
+
new Promise ((resolve, reject) => server.close((e) => (e ? reject(e) : resolve()))),
|
|
319
|
+
};
|
|
76
320
|
}
|
|
@@ -1,24 +1,47 @@
|
|
|
1
1
|
/** Count whole local calendar months; rolling presets are never subscription comparisons. */
|
|
2
|
-
export function fullCalendarMonths(range
|
|
3
|
-
|
|
2
|
+
export function fullCalendarMonths(range
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
) {
|
|
8
|
+
if (range.preset === '7d' || range.preset === '30d') return null;
|
|
4
9
|
// Browser timezone overrides must not change the server's calendar selection.
|
|
5
|
-
if(range.calendarMonths!==undefined)
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
if (range.calendarMonths !== undefined)
|
|
11
|
+
return Number.isInteger(range.calendarMonths) &&
|
|
12
|
+
range.calendarMonths > 0 &&
|
|
13
|
+
range.calendarMonths <= 1200
|
|
14
|
+
? range.calendarMonths
|
|
15
|
+
: null;
|
|
16
|
+
const start = new Date(range.from),
|
|
17
|
+
end = new Date(range.until);
|
|
18
|
+
const boundary = (date ) =>
|
|
19
|
+
Number.isFinite(+date) &&
|
|
20
|
+
date.getDate() === 1 &&
|
|
21
|
+
date.getHours() === 0 &&
|
|
22
|
+
date.getMinutes() === 0 &&
|
|
23
|
+
date.getSeconds() === 0 &&
|
|
24
|
+
date.getMilliseconds() === 0;
|
|
25
|
+
if (!boundary(start) || !boundary(end)) return null;
|
|
26
|
+
const months = (end.getFullYear() - start.getFullYear()) * 12 + end.getMonth() - start.getMonth();
|
|
27
|
+
return months > 0 && months <= 1200 ? months : null;
|
|
11
28
|
}
|
|
12
29
|
/** Civil-date movement uses UTC arithmetic so DST never drops or repeats a day. */
|
|
13
|
-
export function shiftCalendarDays(day
|
|
14
|
-
if(!/^\d{4}-\d{2}-\d{2}$/.test(day)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
30
|
+
export function shiftCalendarDays(day , days ) {
|
|
31
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isInteger(days) || Math.abs(days) > 36600)
|
|
32
|
+
return null;
|
|
33
|
+
const date = new Date(day + 'T12:00:00.000Z');
|
|
34
|
+
if (!Number.isFinite(+date) || date.toISOString().slice(0, 10) !== day) return null;
|
|
35
|
+
date.setUTCDate(date.getUTCDate() + days);
|
|
36
|
+
const result = date.toISOString().slice(0, 10);
|
|
37
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(result) ? result : null;
|
|
19
38
|
}
|
|
20
|
-
export function shiftCalendarMonth(month
|
|
21
|
-
if(!/^(?:19|20|21)\d{2}-(?:0[1-9]|1[0-2])$/.test(month)
|
|
22
|
-
|
|
23
|
-
|
|
39
|
+
export function shiftCalendarMonth(month , months ) {
|
|
40
|
+
if (!/^(?:19|20|21)\d{2}-(?:0[1-9]|1[0-2])$/.test(month) || !Number.isInteger(months))
|
|
41
|
+
return null;
|
|
42
|
+
const ordinal = Number(month.slice(0, 4)) * 12 + Number(month.slice(5)) - 1 + months,
|
|
43
|
+
year = Math.floor(ordinal / 12);
|
|
44
|
+
return year >= 1900 && year <= 2199
|
|
45
|
+
? `${year}-${String((ordinal % 12) + 1).padStart(2, '0')}`
|
|
46
|
+
: null;
|
|
24
47
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Reserve 100% for fully priced usage; missing coverage never becomes zero. */
|
|
2
|
+
export function formatPricingCoverage(coverage ) {
|
|
3
|
+
if (coverage === null || !Number.isFinite(coverage) || coverage < 0 || coverage > 1) {
|
|
4
|
+
return 'Unknown';
|
|
5
|
+
}
|
|
6
|
+
if (coverage === 1) return '100%';
|
|
7
|
+
if (coverage === 0) return '0%';
|
|
8
|
+
const percent = Math.round(coverage * 100);
|
|
9
|
+
if (percent === 100) return '<100%';
|
|
10
|
+
if (percent === 0) return '<1%';
|
|
11
|
+
return `${percent}%`;
|
|
12
|
+
}
|
|
@@ -1,10 +1,62 @@
|
|
|
1
|
-
import {baseRecord} from './metadata.js';
|
|
2
|
-
import {catalog,
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import { baseRecord } from './metadata.js';
|
|
2
|
+
import { catalog, } from './pricing/index.js';
|
|
3
|
+
import {
|
|
4
|
+
defaultSettings,
|
|
5
|
+
emptyDiagnostics,
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
} from './types.js';
|
|
10
|
+
export function demoData(now = new Date())
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
const records = [];
|
|
17
|
+
const monthDays = now.getDate();
|
|
18
|
+
const weights = Array.from(
|
|
19
|
+
{ length: monthDays },
|
|
20
|
+
(_, i) => [4, 6, 5, 8, 7, 10, 6, 8, 11, 9][i % 10],
|
|
21
|
+
);
|
|
22
|
+
const sum = weights.reduce((a, b) => a + b, 0);
|
|
23
|
+
let assigned = 0;
|
|
24
|
+
for (let i = 0; i < monthDays; i++) {
|
|
25
|
+
const r = baseRecord('codex', 'fixture-only', `fixture-${i}`);
|
|
26
|
+
const output =
|
|
27
|
+
i === monthDays - 1 ? 20530000 - assigned : Math.floor((20530000 * weights[i]) / sum);
|
|
28
|
+
assigned += output;
|
|
29
|
+
r.timestamp = new Date(now.getFullYear(), now.getMonth(), i + 1, 12).toISOString();
|
|
30
|
+
r.model_raw = 'gpt-5.3-codex';
|
|
31
|
+
r.session_id = 'fixture-session';
|
|
32
|
+
r.input_tokens = 0;
|
|
33
|
+
r.cached_input_tokens = 0;
|
|
34
|
+
r.cache_creation_tokens = 0;
|
|
35
|
+
r.output_tokens = output;
|
|
36
|
+
r.reasoning_tokens = 0;
|
|
37
|
+
r.total_tokens = output;
|
|
38
|
+
r.source_schema = 'DEMO/fixture';
|
|
39
|
+
r.notes = ['demo-fixture'];
|
|
40
|
+
records.push(r);
|
|
41
|
+
}
|
|
42
|
+
const settings = defaultSettings();
|
|
43
|
+
settings.onboarded = true;
|
|
44
|
+
settings.billing.codex = { mode: 'SUBSCRIPTION', monthly: 200 };
|
|
45
|
+
const statuses = (['codex', 'claude'] ).map((provider) => ({
|
|
46
|
+
provider,
|
|
47
|
+
detected: true,
|
|
48
|
+
files: provider === 'codex' ? 1 : 0,
|
|
49
|
+
scanned: 0,
|
|
50
|
+
cached: 0,
|
|
51
|
+
errors: 0,
|
|
52
|
+
skippedLinks: 0,
|
|
53
|
+
missingFiles: 0,
|
|
54
|
+
diagnostics: emptyDiagnostics(),
|
|
55
|
+
}));
|
|
56
|
+
const demoCatalog = {
|
|
57
|
+
...catalog,
|
|
58
|
+
version: 'DEMO — fixture prices',
|
|
59
|
+
prices: catalog.prices.map((p) => ({ ...p, effective_from: '2000-01-01T00:00:00.000Z' })),
|
|
60
|
+
};
|
|
61
|
+
return { records, settings, statuses, catalog: demoCatalog };
|
|
10
62
|
}
|
|
@@ -1,14 +1,57 @@
|
|
|
1
|
-
import {createHash} from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
export const
|
|
4
|
-
|
|
5
|
-
export const
|
|
6
|
-
|
|
7
|
-
export const
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const isObject = (value ) =>
|
|
4
|
+
value !== null && typeof value === 'object';
|
|
5
|
+
export const isRecord = (value ) =>
|
|
6
|
+
isObject(value) && !Array.isArray(value);
|
|
7
|
+
export const hash = (...parts ) =>
|
|
8
|
+
createHash('sha256').update(JSON.stringify(parts)).digest('hex');
|
|
9
|
+
export const token = (v ) =>
|
|
10
|
+
typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : null;
|
|
11
|
+
export const identifier = (v ) =>
|
|
12
|
+
typeof v === 'string' && /^[a-zA-Z0-9._:@/-]{1,160}$/.test(v) ? v : null;
|
|
13
|
+
export const modelId = (v ) =>
|
|
14
|
+
typeof v === 'string' && /^[a-zA-Z0-9._:-]{1,100}$/.test(v) ? v : null;
|
|
15
|
+
export const timestamp = (v ) =>
|
|
16
|
+
typeof v === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(v) && Number.isFinite(Date.parse(v))
|
|
17
|
+
? new Date(v).toISOString()
|
|
18
|
+
: null;
|
|
8
19
|
export function baseRecord(provider , ref , event ) {
|
|
9
|
-
return {
|
|
20
|
+
return {
|
|
21
|
+
id: hash(provider, event),
|
|
22
|
+
provider,
|
|
23
|
+
source_event_id: event,
|
|
24
|
+
source_file_reference: ref,
|
|
25
|
+
timestamp: null,
|
|
26
|
+
session_id: null,
|
|
27
|
+
request_id: null,
|
|
28
|
+
thread_id: null,
|
|
29
|
+
model_raw: null,
|
|
30
|
+
model_canonical: null,
|
|
31
|
+
billing_mode: 'UNKNOWN',
|
|
32
|
+
input_tokens: null,
|
|
33
|
+
cached_input_tokens: null,
|
|
34
|
+
cache_creation_tokens: null,
|
|
35
|
+
cache_creation_5m_tokens: null,
|
|
36
|
+
cache_creation_1h_tokens: null,
|
|
37
|
+
output_tokens: null,
|
|
38
|
+
reasoning_tokens: null,
|
|
39
|
+
unallocated_total_tokens: null,
|
|
40
|
+
total_tokens: null,
|
|
41
|
+
project_identifier_hash: null,
|
|
42
|
+
quality: 'HIGH',
|
|
43
|
+
source_schema: 'unknown',
|
|
44
|
+
notes: [],
|
|
45
|
+
service_tier: null,
|
|
46
|
+
speed: null,
|
|
47
|
+
web_search_requests: null,
|
|
48
|
+
web_fetch_requests: null,
|
|
49
|
+
};
|
|
10
50
|
}
|
|
11
51
|
export function track(d , r ) {
|
|
12
52
|
d.records++;
|
|
13
|
-
if(r.timestamp){
|
|
53
|
+
if (r.timestamp) {
|
|
54
|
+
d.first = !d.first || r.timestamp < d.first ? r.timestamp : d.first;
|
|
55
|
+
d.last = !d.last || r.timestamp > d.last ? r.timestamp : d.last;
|
|
56
|
+
}
|
|
14
57
|
}
|