@kvbox/server 0.5.5 → 0.5.6

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.
@@ -102,7 +102,7 @@ function paths(home = kvboxHome()) {
102
102
 
103
103
  // src/core/config.ts
104
104
  import { chmod, readFile, rename, writeFile } from "node:fs/promises";
105
- import { z } from "zod";
105
+ import { z as z2 } from "zod";
106
106
 
107
107
  // ../shared/src/sizes.ts
108
108
  var SIZES = {
@@ -116,6 +116,107 @@ function isSizeName(value) {
116
116
  return value in SIZES;
117
117
  }
118
118
 
119
+ // ../shared/src/usage.ts
120
+ import { z } from "zod";
121
+ var PricingSchema = z.object({
122
+ currency: z.string().min(1),
123
+ vcpuHour: z.number().nonnegative(),
124
+ ramGibHour: z.number().nonnegative(),
125
+ diskGibHour: z.number().nonnegative()
126
+ });
127
+ var HOUR_MS = 36e5;
128
+ var round = (n, places) => Math.round(n * 10 ** places) / 10 ** places;
129
+ function computeUsage(events, w) {
130
+ const sorted = [...events].sort((a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0);
131
+ const from = Date.parse(w.from);
132
+ const to = Date.parse(w.to);
133
+ const ms = { running: 0, standby: 0, hibernated: 0, stopped: 0 };
134
+ let vcpuHours = 0;
135
+ let ramGibHours = 0;
136
+ let diskGibHours = 0;
137
+ sorted.forEach((e, i) => {
138
+ const start = Math.max(Date.parse(e.at), from);
139
+ const next = sorted[i + 1];
140
+ const end = Math.min(next === void 0 ? to : Date.parse(next.at), to);
141
+ if (end <= start) return;
142
+ const h = (end - start) / HOUR_MS;
143
+ ms[e.state] += end - start;
144
+ if (e.state === "running") vcpuHours += h * e.vcpu;
145
+ if (e.state === "running" || e.state === "standby") ramGibHours += h * e.memMib / 1024;
146
+ diskGibHours += h * e.diskGib;
147
+ });
148
+ const totals = {
149
+ seconds: {
150
+ running: Math.round(ms.running / 1e3),
151
+ standby: Math.round(ms.standby / 1e3),
152
+ hibernated: Math.round(ms.hibernated / 1e3),
153
+ stopped: Math.round(ms.stopped / 1e3)
154
+ },
155
+ vcpuHours: round(vcpuHours, 3),
156
+ ramGibHours: round(ramGibHours, 3),
157
+ diskGibHours: round(diskGibHours, 3),
158
+ estimatedStops: sorted.filter((e) => e.state === "stopped" && e.by === "server" && e.at >= w.from).length,
159
+ historyFrom: sorted[0]?.at ?? null
160
+ };
161
+ if (w.pricing !== void 0) totals.cost = { amount: round(costOf(totals, w.pricing), 4), currency: w.pricing.currency };
162
+ return totals;
163
+ }
164
+ function costOf(t, pricing) {
165
+ return t.vcpuHours * pricing.vcpuHour + t.ramGibHours * pricing.ramGibHour + t.diskGibHours * pricing.diskGibHour;
166
+ }
167
+ function sumUsage(items, pricing) {
168
+ const sum = {
169
+ seconds: { running: 0, standby: 0, hibernated: 0, stopped: 0 },
170
+ vcpuHours: 0,
171
+ ramGibHours: 0,
172
+ diskGibHours: 0
173
+ };
174
+ for (const t of items) {
175
+ for (const s of Object.keys(sum.seconds)) sum.seconds[s] += t.seconds[s];
176
+ sum.vcpuHours += t.vcpuHours;
177
+ sum.ramGibHours += t.ramGibHours;
178
+ sum.diskGibHours += t.diskGibHours;
179
+ }
180
+ sum.vcpuHours = round(sum.vcpuHours, 3);
181
+ sum.ramGibHours = round(sum.ramGibHours, 3);
182
+ sum.diskGibHours = round(sum.diskGibHours, 3);
183
+ if (pricing !== void 0) sum.cost = { amount: round(costOf(sum, pricing), 4), currency: pricing.currency };
184
+ return sum;
185
+ }
186
+ function usageWindow(since, until, now = /* @__PURE__ */ new Date()) {
187
+ const parse = (name, value) => {
188
+ const t = Date.parse(value);
189
+ if (Number.isNaN(t)) throw new Error(`${name}: "${value}" is not a date; write it like 2026-10-19 or 2026-10-19T08:00:00Z`);
190
+ return new Date(t).toISOString();
191
+ };
192
+ const from = since === void 0 ? new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString() : parse("since", since);
193
+ const to = until === void 0 ? now.toISOString() : parse("until", until);
194
+ return { from, to };
195
+ }
196
+ function groupUsage(rows, window, pricing, live = /* @__PURE__ */ new Map()) {
197
+ const byBox = /* @__PURE__ */ new Map();
198
+ for (const r of rows) {
199
+ const key = r.ledger === void 0 ? r.boxId : `${r.ledger}:${r.boxId}`;
200
+ const list = byBox.get(key);
201
+ if (list === void 0) byBox.set(key, [r]);
202
+ else list.push(r);
203
+ }
204
+ const out = [];
205
+ for (const events of byBox.values()) {
206
+ events.sort((a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0);
207
+ const first = events[0];
208
+ const last = events[events.length - 1];
209
+ const id = last.boxId;
210
+ const removed = last.by === "rm" && !live.has(id);
211
+ const to = removed && last.at < window.to ? last.at : window.to;
212
+ if (first.at >= to || removed && last.at <= window.from) continue;
213
+ const totals = computeUsage(events, { from: window.from, to, pricing });
214
+ const status = live.get(id) ?? (removed ? "removed" : last.state);
215
+ out.push({ id, name: last.name, owner: last.owner, status, ...totals });
216
+ }
217
+ return out.sort((a, b) => a.owner.localeCompare(b.owner) || a.name.localeCompare(b.name) || (a.historyFrom ?? "").localeCompare(b.historyFrom ?? ""));
218
+ }
219
+
119
220
  // src/core/refs.ts
120
221
  import { userInfo } from "node:os";
121
222
  function shown(ref) {
@@ -161,41 +262,35 @@ function refKey(ref) {
161
262
  }
162
263
 
163
264
  // src/core/config.ts
164
- var PricingSchema = z.object({
165
- currency: z.string().min(1),
166
- vcpuHour: z.number().nonnegative(),
167
- ramGibHour: z.number().nonnegative(),
168
- diskGibHour: z.number().nonnegative()
169
- });
170
- var ControlPlaneSchema = z.object({
171
- url: z.string().url(),
265
+ var ControlPlaneSchema = z2.object({
266
+ url: z2.string().url(),
172
267
  /** The realm name: boxes the control plane creates are owned by `<user>@<name>`, a directory name on disk. */
173
- name: z.string().regex(REALM_RE),
268
+ name: z2.string().regex(REALM_RE),
174
269
  /** Minted at join; every HTTPS call from the control plane carries it as a bearer. */
175
- secret: z.string().min(1),
270
+ secret: z2.string().min(1),
176
271
  /** The address the control plane reaches this host at. */
177
- advertise: z.string().min(1),
178
- port: z.number().int().positive().default(7777)
272
+ advertise: z2.string().min(1),
273
+ port: z2.number().int().positive().default(7777)
179
274
  });
180
- var RelaySchema = z.object({
181
- name: z.string().min(1),
182
- publicIp: z.string().min(1),
183
- tunnelIp: z.string().min(1),
275
+ var RelaySchema = z2.object({
276
+ name: z2.string().min(1),
277
+ publicIp: z2.string().min(1),
278
+ tunnelIp: z2.string().min(1),
184
279
  /** `<relay-ip>:<port>` the tunnel dials. */
185
- endpoint: z.string().min(1)
280
+ endpoint: z2.string().min(1)
186
281
  });
187
- var ConfigSchema = z.object({
188
- kernel: z.string(),
189
- rootfs: z.string(),
190
- firecracker: z.string(),
191
- snapshotEditor: z.string().optional(),
282
+ var ConfigSchema = z2.object({
283
+ kernel: z2.string(),
284
+ rootfs: z2.string(),
285
+ firecracker: z2.string(),
286
+ snapshotEditor: z2.string().optional(),
192
287
  // default: bin/snapshot-editor next to firecracker
193
- bridge: z.string().default("kvbox0"),
194
- subnet: z.string().default("172.30.0.0/24"),
195
- gateway: z.string().default("172.30.0.1"),
196
- defaultSize: z.enum(SIZE_NAMES).default(DEFAULT_SIZE),
288
+ bridge: z2.string().default("kvbox0"),
289
+ subnet: z2.string().default("172.30.0.0/24"),
290
+ gateway: z2.string().default("172.30.0.1"),
291
+ defaultSize: z2.enum(SIZE_NAMES).default(DEFAULT_SIZE),
197
292
  /** Who boxes made over the local socket belong to: the Unix user running the server unless set here. No `@`, no `/`. */
198
- owner: z.string().regex(LOCAL_OWNER_RE, "owner: letters, digits, '.', '_', '-'; no '@' or '/'").default(localOwner),
293
+ owner: z2.string().regex(LOCAL_OWNER_RE, "owner: letters, digits, '.', '_', '-'; no '@' or '/'").default(localOwner),
199
294
  pricing: PricingSchema.optional(),
200
295
  /** Absent until the host joins a control plane; then the server also listens on HTTPS. */
201
296
  controlPlane: ControlPlaneSchema.optional(),
@@ -239,247 +334,283 @@ import { hostname as osHostname } from "node:os";
239
334
  import { request } from "undici";
240
335
 
241
336
  // ../shared/src/api.ts
242
- import { z as z2 } from "zod";
243
- var SizeSchema = z2.enum(SIZE_NAMES);
244
- var BoxStatusSchema = z2.enum(["running", "standby", "hibernated", "stopped", "unknown"]);
245
- var MachineSchema = z2.object({
337
+ import { z as z3 } from "zod";
338
+ var SizeSchema = z3.enum(SIZE_NAMES);
339
+ var BoxStatusSchema = z3.enum(["running", "standby", "hibernated", "stopped", "unknown"]);
340
+ var MachineSchema = z3.object({
246
341
  /** Host-unique short id (the tap is `kv-<id>`). Missing only on a box made before ids that has not restarted since. */
247
- id: z2.string(),
248
- name: z2.string(),
342
+ id: z3.string(),
343
+ name: z3.string(),
249
344
  /** Who the box belongs to; names are unique per owner. A bare name is local to the host, `user@control-plane` later. */
250
- owner: z2.string(),
251
- ip: z2.string(),
252
- mac: z2.string(),
253
- pid: z2.number().int().nullable(),
254
- vcpu: z2.number().int(),
255
- memMib: z2.number().int(),
256
- diskBytes: z2.number().int(),
345
+ owner: z3.string(),
346
+ ip: z3.string(),
347
+ mac: z3.string(),
348
+ pid: z3.number().int().nullable(),
349
+ vcpu: z3.number().int(),
350
+ memMib: z3.number().int(),
351
+ diskBytes: z3.number().int(),
257
352
  // the box's own writable disk, as the guest sees it
258
- diskUsedBytes: z2.number().int(),
353
+ diskUsedBytes: z3.number().int(),
259
354
  // what that sparse file really occupies on the host
260
- base: z2.string(),
355
+ base: z3.string(),
261
356
  // base image version the box's overlay stacks on
262
- createdAt: z2.string(),
263
- source: z2.string(),
357
+ createdAt: z3.string(),
358
+ source: z3.string(),
264
359
  // "base" or "snapshot:<name>"
265
360
  status: BoxStatusSchema,
266
361
  /**
267
362
  * Public TCP port on the host that reaches the box's SSH while it runs (#49). Only boxes made
268
363
  * through a control plane have one; the control plane adds the host's address for `connect` (#21).
269
364
  */
270
- sshPort: z2.number().int().optional(),
365
+ sshPort: z3.number().int().optional(),
271
366
  /**
272
367
  * Where `ssh` reaches the box from outside: the host's public address and that port (#21). Only
273
368
  * the control plane fills it in (a host does not know its own public address); absent for a box
274
369
  * whose host reported no port or did not answer.
275
370
  */
276
- ssh: z2.object({ host: z2.string(), port: z2.number().int(), user: z2.string() }).optional(),
371
+ ssh: z3.object({ host: z3.string(), port: z3.number().int(), user: z3.string() }).optional(),
277
372
  /** Whether the owner slug is a user or an org. Only the control plane knows; it fills it in on `GET /machines`. */
278
- ownerKind: z2.enum(["user", "org"]).optional()
373
+ ownerKind: z3.enum(["user", "org"]).optional()
279
374
  });
280
- var CreateMachineRequest = z2.object({
281
- name: z2.string().optional(),
375
+ var CreateMachineRequest = z3.object({
376
+ name: z3.string().optional(),
282
377
  /** Ignored over the local socket (the caller is the host owner); the control plane sets it over HTTPS. */
283
- owner: z2.string().optional(),
378
+ owner: z3.string().optional(),
284
379
  size: SizeSchema.optional(),
285
- from: z2.string().optional(),
286
- force: z2.boolean().optional(),
380
+ from: z3.string().optional(),
381
+ force: z3.boolean().optional(),
287
382
  /**
288
383
  * Extra authorized_keys lines for the box, on top of the host's own key: the control plane sends
289
384
  * its SSH CA as one `cert-authority …` line (#51). Written into the box's authorized_keys at every
290
385
  * boot (#49); a host older than that ignores them.
291
386
  */
292
- sshKeys: z2.array(z2.string()).optional()
387
+ sshKeys: z3.array(z3.string()).optional()
293
388
  });
294
- var SetSshKeysRequest = z2.object({
295
- sshKeys: z2.array(z2.string())
389
+ var SetSshKeysRequest = z3.object({
390
+ sshKeys: z3.array(z3.string())
296
391
  });
297
- var RescaleMachineRequest = z2.object({
392
+ var RescaleMachineRequest = z3.object({
298
393
  size: SizeSchema.optional(),
299
- vcpu: z2.number().int().min(1).max(64).optional(),
300
- memMib: z2.number().int().min(256).optional(),
301
- diskGib: z2.number().int().min(1).optional()
394
+ vcpu: z3.number().int().min(1).max(64).optional(),
395
+ memMib: z3.number().int().min(256).optional(),
396
+ diskGib: z3.number().int().min(1).optional()
302
397
  });
303
- var SnapshotSchema = z2.object({
304
- name: z2.string(),
305
- owner: z2.string(),
306
- source: z2.string(),
398
+ var SnapshotSchema = z3.object({
399
+ name: z3.string(),
400
+ owner: z3.string(),
401
+ source: z3.string(),
307
402
  // the box it was taken from, as owner/name
308
- base: z2.string(),
403
+ base: z3.string(),
309
404
  // base image version the overlay stacks on; a restore needs the same one
310
- vcpu: z2.number().int(),
311
- memMib: z2.number().int(),
312
- sizeBytes: z2.number().int(),
405
+ vcpu: z3.number().int(),
406
+ memMib: z3.number().int(),
407
+ sizeBytes: z3.number().int(),
313
408
  // what vmstate + mem + overlay really occupy on the host
314
- createdAt: z2.string()
409
+ createdAt: z3.string()
315
410
  });
316
- var CreateSnapshotRequest = z2.object({
317
- box: z2.string(),
318
- name: z2.string()
411
+ var CreateSnapshotRequest = z3.object({
412
+ box: z3.string(),
413
+ name: z3.string()
319
414
  });
320
- var CapacitySchema = z2.object({
321
- hostMemMib: z2.number().int(),
322
- hostCores: z2.number().int(),
323
- reservedMemMib: z2.number().int(),
324
- reservedVcpu: z2.number().int(),
325
- running: z2.number().int()
415
+ var CapacitySchema = z3.object({
416
+ hostMemMib: z3.number().int(),
417
+ hostCores: z3.number().int(),
418
+ reservedMemMib: z3.number().int(),
419
+ reservedVcpu: z3.number().int(),
420
+ running: z3.number().int()
326
421
  });
327
- var HealthSchema = z2.object({
328
- ok: z2.literal(true),
329
- version: z2.string(),
330
- pid: z2.number().int(),
422
+ var HealthSchema = z3.object({
423
+ ok: z3.literal(true),
424
+ version: z3.string(),
425
+ pid: z3.number().int(),
331
426
  /** Base image version new boxes get (null when the host has no usable base image yet). */
332
- base: z2.string().nullable(),
427
+ base: z3.string().nullable(),
333
428
  /** Owner that boxes made over the local socket get (the Unix user running the server). */
334
- owner: z2.string(),
429
+ owner: z3.string(),
335
430
  /** The control plane this host joined (null when none). */
336
- controlPlane: z2.object({
431
+ controlPlane: z3.object({
337
432
  /** Realm name, e.g. kvbox.sh. */
338
- name: z2.string(),
339
- url: z2.string(),
433
+ name: z3.string(),
434
+ url: z3.string(),
340
435
  /** Where this host answers the control plane. */
341
- hostUrl: z2.string()
436
+ hostUrl: z3.string()
342
437
  }).nullable(),
343
438
  /** The relay this host reaches the internet through (#50); null when it has its own public address. Older servers omit it. */
344
- relay: z2.object({
345
- name: z2.string(),
439
+ relay: z3.object({
440
+ name: z3.string(),
346
441
  /** The public IP the relay owns for this host. */
347
- publicIp: z2.string(),
442
+ publicIp: z3.string(),
348
443
  /** The relay's WireGuard endpoint the tunnel dials. */
349
- endpoint: z2.string()
444
+ endpoint: z3.string()
350
445
  }).nullable().optional()
351
446
  });
352
- var MachineUsageSchema = z2.object({
353
- name: z2.string(),
354
- owner: z2.string(),
447
+ var MachineUsageSchema = z3.object({
448
+ name: z3.string(),
449
+ owner: z3.string(),
355
450
  status: BoxStatusSchema,
356
- window: z2.object({ from: z2.string(), to: z2.string() }),
357
- seconds: z2.object({
358
- running: z2.number().int(),
359
- standby: z2.number().int(),
360
- hibernated: z2.number().int(),
361
- stopped: z2.number().int()
451
+ window: z3.object({ from: z3.string(), to: z3.string() }),
452
+ seconds: z3.object({
453
+ running: z3.number().int(),
454
+ standby: z3.number().int(),
455
+ hibernated: z3.number().int(),
456
+ stopped: z3.number().int()
362
457
  }),
363
- vcpuHours: z2.number(),
364
- ramGibHours: z2.number(),
365
- diskGibHours: z2.number(),
366
- cost: z2.object({ amount: z2.number(), currency: z2.string() }).optional(),
458
+ vcpuHours: z3.number(),
459
+ ramGibHours: z3.number(),
460
+ diskGibHours: z3.number(),
461
+ cost: z3.object({ amount: z3.number(), currency: z3.string() }).optional(),
367
462
  /** Stops the server did not witness (Firecracker died, host rebooted) and dated to when it noticed. */
368
- estimatedStops: z2.number().int(),
369
- historyFrom: z2.string().nullable()
463
+ estimatedStops: z3.number().int(),
464
+ historyFrom: z3.string().nullable()
465
+ });
466
+ var UsageStatusSchema = z3.enum([...BoxStatusSchema.options, "removed"]);
467
+ var BoxUsageSchema = MachineUsageSchema.omit({ window: true, status: true }).extend({
468
+ /** The host's box id; a name removed and recreated appears twice, with two ids. */
469
+ id: z3.string(),
470
+ status: UsageStatusSchema,
471
+ /** Through a control plane: whether the owner slug is a user's or an org's (what `kvbox ls` shows). */
472
+ ownerKind: z3.enum(["user", "org"]).optional()
370
473
  });
371
- var ApiErrorSchema = z2.object({
372
- error: z2.string()
474
+ var UsageReportSchema = z3.object({
475
+ window: z3.object({ from: z3.string(), to: z3.string() }),
476
+ boxes: z3.array(BoxUsageSchema),
477
+ totals: z3.object({
478
+ seconds: MachineUsageSchema.shape.seconds,
479
+ vcpuHours: z3.number(),
480
+ ramGibHours: z3.number(),
481
+ diskGibHours: z3.number(),
482
+ cost: z3.object({ amount: z3.number(), currency: z3.string() }).optional()
483
+ })
373
484
  });
374
- var CloudUserSchema = z2.object({
485
+ var UsageEventRowSchema = z3.object({
486
+ id: z3.number().int(),
487
+ boxId: z3.string(),
488
+ owner: z3.string(),
489
+ name: z3.string(),
490
+ at: z3.string(),
491
+ state: z3.enum(["running", "standby", "hibernated", "stopped"]),
492
+ vcpu: z3.number().int(),
493
+ memMib: z3.number().int(),
494
+ diskGib: z3.number().int(),
495
+ by: z3.string()
496
+ });
497
+ var UsageEventsResponseSchema = z3.object({
498
+ ledger: z3.string().min(1),
499
+ events: z3.array(UsageEventRowSchema),
500
+ next: z3.number().int()
501
+ });
502
+ var ApiErrorSchema = z3.object({
503
+ error: z3.string()
504
+ });
505
+ var CloudUserSchema = z3.object({
375
506
  /** The handle: what hosts file boxes under (`<username>@<control plane>`). Lowercase, no `@` or `/`. */
376
- username: z2.string(),
507
+ username: z3.string(),
377
508
  /** Display name, free text ("Alexandru Ghiura"). */
378
- name: z2.string(),
509
+ name: z3.string(),
379
510
  /** Login identity, unique. */
380
- email: z2.string(),
381
- role: z2.enum(["admin", "user"]),
382
- createdAt: z2.string()
511
+ email: z3.string(),
512
+ role: z3.enum(["admin", "user"]),
513
+ createdAt: z3.string()
383
514
  });
384
515
  var ORG_ROLES = ["owner", "admin", "member"];
385
- var OrgRoleSchema = z2.enum(ORG_ROLES);
386
- var CloudOrgSchema = z2.object({
387
- slug: z2.string(),
516
+ var OrgRoleSchema = z3.enum(ORG_ROLES);
517
+ var CloudOrgSchema = z3.object({
518
+ slug: z3.string(),
388
519
  /** Display name; the slug never changes, this may. */
389
- name: z2.string(),
390
- createdAt: z2.string()
520
+ name: z3.string(),
521
+ createdAt: z3.string()
391
522
  });
392
523
  var OrgMembershipSchema = CloudOrgSchema.extend({ role: OrgRoleSchema });
393
- var OrgMemberSchema = z2.object({
394
- username: z2.string(),
395
- name: z2.string(),
524
+ var OrgMemberSchema = z3.object({
525
+ username: z3.string(),
526
+ name: z3.string(),
396
527
  role: OrgRoleSchema,
397
- addedAt: z2.string()
528
+ addedAt: z3.string()
398
529
  });
399
- var OrgDetailSchema = CloudOrgSchema.extend({ members: z2.array(OrgMemberSchema) });
400
- var CreateOrgRequestSchema = z2.object({
401
- slug: z2.string().min(1),
402
- name: z2.string().min(1)
530
+ var OrgDetailSchema = CloudOrgSchema.extend({ members: z3.array(OrgMemberSchema) });
531
+ var CreateOrgRequestSchema = z3.object({
532
+ slug: z3.string().min(1),
533
+ name: z3.string().min(1)
403
534
  });
404
- var RenameOrgRequestSchema = z2.object({ name: z2.string().min(1) });
405
- var AddMemberRequestSchema = z2.object({
406
- username: z2.string().min(1),
535
+ var RenameOrgRequestSchema = z3.object({ name: z3.string().min(1) });
536
+ var AddMemberRequestSchema = z3.object({
537
+ username: z3.string().min(1),
407
538
  role: OrgRoleSchema.default("member")
408
539
  });
409
- var SetRoleRequestSchema = z2.object({ role: OrgRoleSchema });
410
- var MeResponseSchema = CloudUserSchema.extend({ orgs: z2.array(OrgMembershipSchema) });
411
- var LoginRequestSchema = z2.object({
412
- email: z2.string().min(1),
413
- password: z2.string().min(1),
414
- publicKey: z2.string().optional()
540
+ var SetRoleRequestSchema = z3.object({ role: OrgRoleSchema });
541
+ var MeResponseSchema = CloudUserSchema.extend({ orgs: z3.array(OrgMembershipSchema) });
542
+ var LoginRequestSchema = z3.object({
543
+ email: z3.string().min(1),
544
+ password: z3.string().min(1),
545
+ publicKey: z3.string().optional()
415
546
  });
416
- var SignupRequestSchema = z2.object({
417
- username: z2.string().min(1),
418
- name: z2.string().min(1),
419
- email: z2.string().min(1),
420
- password: z2.string().min(1)
547
+ var SignupRequestSchema = z3.object({
548
+ username: z3.string().min(1),
549
+ name: z3.string().min(1),
550
+ email: z3.string().min(1),
551
+ password: z3.string().min(1)
421
552
  });
422
- var LoginResponseSchema = z2.object({
423
- token: z2.string(),
553
+ var LoginResponseSchema = z3.object({
554
+ token: z3.string(),
424
555
  user: CloudUserSchema,
425
- sshCert: z2.string().optional(),
426
- sshCertExpires: z2.string().optional(),
556
+ sshCert: z3.string().optional(),
557
+ sshCertExpires: z3.string().optional(),
427
558
  /** Whose boxes the certificate opens: `<slug>@<realm>` for the user and each org they are in (orgs phase 4). */
428
- sshCertPrincipals: z2.array(z2.string()).optional()
559
+ sshCertPrincipals: z3.array(z3.string()).optional()
429
560
  });
430
- var SshCertRequestSchema = z2.object({
431
- publicKey: z2.string().min(1)
561
+ var SshCertRequestSchema = z3.object({
562
+ publicKey: z3.string().min(1)
432
563
  });
433
- var SshCertResponseSchema = z2.object({
434
- cert: z2.string(),
435
- expires: z2.string(),
436
- principals: z2.array(z2.string())
564
+ var SshCertResponseSchema = z3.object({
565
+ cert: z3.string(),
566
+ expires: z3.string(),
567
+ principals: z3.array(z3.string())
437
568
  });
438
- var SshCaResponseSchema = z2.object({
439
- publicKey: z2.string(),
569
+ var SshCaResponseSchema = z3.object({
570
+ publicKey: z3.string(),
440
571
  /** CAs replaced by `ca rotate` (#55), newest first; a box still holding one of these has not had `kvbox server trust-ca` run on its host. */
441
- retired: z2.array(z2.object({ publicKey: z2.string(), retiredAt: z2.string() })).optional()
572
+ retired: z3.array(z3.object({ publicKey: z3.string(), retiredAt: z3.string() })).optional()
442
573
  });
443
- var HostSshCaResponseSchema = z2.object({
444
- publicKey: z2.string()
574
+ var HostSshCaResponseSchema = z3.object({
575
+ publicKey: z3.string()
445
576
  });
446
- var CloudCaSchema = z2.object({
447
- controlPlane: z2.string(),
448
- publicKey: z2.string(),
449
- fingerprint: z2.string()
577
+ var CloudCaSchema = z3.object({
578
+ controlPlane: z3.string(),
579
+ publicKey: z3.string(),
580
+ fingerprint: z3.string()
450
581
  });
451
- var TrustCaRequestSchema = z2.object({
452
- publicKey: z2.string().min(1)
582
+ var TrustCaRequestSchema = z3.object({
583
+ publicKey: z3.string().min(1)
453
584
  });
454
- var SshKeysAppliedSchema = z2.enum(["now", "start", "wake", "resume", "later"]);
455
- var TrustCaReportSchema = z2.object({
456
- publicKey: z2.string(),
457
- fingerprint: z2.string(),
585
+ var SshKeysAppliedSchema = z3.enum(["now", "start", "wake", "resume", "later"]);
586
+ var TrustCaReportSchema = z3.object({
587
+ publicKey: z3.string(),
588
+ fingerprint: z3.string(),
458
589
  /** The guest trusts the CA now. */
459
- updated: z2.array(z2.string()),
590
+ updated: z3.array(z3.string()),
460
591
  /** meta.json has the line; the guest gets it at `when`. */
461
- pending: z2.array(z2.object({ box: z2.string(), when: SshKeysAppliedSchema, why: z2.string().optional() })),
462
- failed: z2.array(z2.object({ box: z2.string(), error: z2.string() })),
592
+ pending: z3.array(z3.object({ box: z3.string(), when: SshKeysAppliedSchema, why: z3.string().optional() })),
593
+ failed: z3.array(z3.object({ box: z3.string(), error: z3.string() })),
463
594
  /** Local boxes, untouched: they trust the host's key only. */
464
- local: z2.number().int()
595
+ local: z3.number().int()
465
596
  });
466
- var HostJoinRequestSchema = z2.object({
467
- token: z2.string().min(1),
468
- name: z2.string().min(1),
469
- url: z2.string().url(),
470
- fingerprint: z2.string().regex(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/),
471
- publicAddress: z2.string().min(1)
597
+ var HostJoinRequestSchema = z3.object({
598
+ token: z3.string().min(1),
599
+ name: z3.string().min(1),
600
+ url: z3.string().url(),
601
+ fingerprint: z3.string().regex(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/),
602
+ publicAddress: z3.string().min(1)
472
603
  });
473
- var HostJoinResponseSchema = z2.object({
474
- hostSecret: z2.string(),
475
- controlPlane: z2.object({ name: z2.string() })
604
+ var HostJoinResponseSchema = z3.object({
605
+ hostSecret: z3.string(),
606
+ controlPlane: z3.object({ name: z3.string() })
476
607
  });
477
- var CloudHostSchema = z2.object({
478
- name: z2.string(),
479
- url: z2.string(),
480
- publicAddress: z2.string(),
481
- joinedAt: z2.string(),
482
- lastSeenAt: z2.string().nullable(),
608
+ var CloudHostSchema = z3.object({
609
+ name: z3.string(),
610
+ url: z3.string(),
611
+ publicAddress: z3.string(),
612
+ joinedAt: z3.string(),
613
+ lastSeenAt: z3.string().nullable(),
483
614
  capacity: CapacitySchema.nullable()
484
615
  });
485
616
 
@@ -809,24 +940,24 @@ function macFor(ip) {
809
940
  import { chmod as chmod3, mkdir as mkdir2, rm, writeFile as writeFile2 } from "node:fs/promises";
810
941
 
811
942
  // ../shared/src/relay.ts
812
- import { z as z3 } from "zod";
943
+ import { z as z4 } from "zod";
813
944
  var RELAY_TUNNEL_GATEWAY = "10.77.0.1";
814
945
  var IPV4_RE = /^(\d{1,3})(\.\d{1,3}){3}$/;
815
946
  var WG_KEY_RE = /^[A-Za-z0-9+/]{43}=$/;
816
- var RelayJoinSchema = z3.object({
817
- v: z3.literal(1),
947
+ var RelayJoinSchema = z4.object({
948
+ v: z4.literal(1),
818
949
  /** How the relay lists this host. */
819
- name: z3.string().regex(/^[a-z0-9][a-z0-9-]{0,30}$/),
950
+ name: z4.string().regex(/^[a-z0-9][a-z0-9-]{0,30}$/),
820
951
  /** `<relay-primary-ip>:<port>`, the WireGuard endpoint the host dials. */
821
- relay: z3.string().regex(/^(\d{1,3})(\.\d{1,3}){3}:\d{1,5}$/),
952
+ relay: z4.string().regex(/^(\d{1,3})(\.\d{1,3}){3}:\d{1,5}$/),
822
953
  /** The relay's WireGuard public key. */
823
- relayKey: z3.string().regex(WG_KEY_RE),
954
+ relayKey: z4.string().regex(WG_KEY_RE),
824
955
  /** This host's address inside the tunnel. */
825
- tunnelIp: z3.string().regex(IPV4_RE),
956
+ tunnelIp: z4.string().regex(IPV4_RE),
826
957
  /** This host's WireGuard private key, generated on the relay, kept nowhere else. */
827
- privateKey: z3.string().regex(WG_KEY_RE),
958
+ privateKey: z4.string().regex(WG_KEY_RE),
828
959
  /** The extra public IPv4 the relay owns for this host; what the host advertises to the control plane. */
829
- publicIp: z3.string().regex(IPV4_RE)
960
+ publicIp: z4.string().regex(IPV4_RE)
830
961
  });
831
962
  function decodeRelayJoin(text) {
832
963
  let parsed;
@@ -900,44 +1031,44 @@ import { createAdaptorServer } from "@hono/node-server";
900
1031
  // src/core/state.ts
901
1032
  import { randomBytes } from "node:crypto";
902
1033
  import { mkdir as mkdir3, readFile as readFile4, readdir, writeFile as writeFile3 } from "node:fs/promises";
903
- import { z as z4 } from "zod";
904
- var MachineMetaSchema = z4.object({
1034
+ import { z as z5 } from "zod";
1035
+ var MachineMetaSchema = z5.object({
905
1036
  /** Host-unique, minted at `kvbox new` (fresh or restored) and kept for the box's life; the tap is `kv-<id>`. */
906
- id: z4.string(),
907
- name: z4.string(),
1037
+ id: z5.string(),
1038
+ name: z5.string(),
908
1039
  /** Part of the box's identity: machines/<owner>/<name>. See refs.ts. */
909
- owner: z4.string(),
910
- ip: z4.string(),
911
- mac: z4.string(),
912
- pid: z4.number().int().nullable(),
1040
+ owner: z5.string(),
1041
+ ip: z5.string(),
1042
+ mac: z5.string(),
1043
+ pid: z5.number().int().nullable(),
913
1044
  // null: stopped on purpose, disk kept
914
- vcpu: z4.number().int(),
915
- memMib: z4.number().int(),
916
- createdAt: z4.string(),
917
- source: z4.string(),
1045
+ vcpu: z5.number().int(),
1046
+ memMib: z5.number().int(),
1047
+ createdAt: z5.string(),
1048
+ source: z5.string(),
918
1049
  /** Base image version (assets/rootfs-<version>.ext4) the box's overlay stacks on. */
919
- base: z4.string(),
1050
+ base: z5.string(),
920
1051
  /**
921
1052
  * Snapshots taken since this VM started (boot or restore). Firecracker resets its dirty-page bitmap at
922
1053
  * each one, so when this is > 0 the next diff is only standalone merged onto `mem.base`; if that file
923
1054
  * is missing the lineage is broken and the next snapshot has to be a full dump. Absent: 0.
924
1055
  */
925
- memSnapshots: z4.number().int().optional(),
1056
+ memSnapshots: z5.number().int().optional(),
926
1057
  /** `kvbox pause`: Firecracker is alive but the VM is frozen (standby). Absent: not paused. */
927
- paused: z4.boolean().optional(),
1058
+ paused: z5.boolean().optional(),
928
1059
  /**
929
1060
  * authorized_keys lines the box gets on top of the host's own key, as the control plane sent them
930
1061
  * at `new` (#49). kvbox-init rewrites authorized_keys from the kernel cmdline at every boot, so
931
1062
  * `start` has to send the same lines again. Absent: host key only.
932
1063
  */
933
- sshKeys: z4.array(z4.string()).optional(),
1064
+ sshKeys: z5.array(z5.string()).optional(),
934
1065
  /**
935
1066
  * The guest's authorized_keys may not match `sshKeys`: they changed while the box was not running,
936
1067
  * or its agent could not take them (#55, #63). Cleared by a push through kvbox-agent or a boot.
937
1068
  */
938
- sshKeysStale: z4.boolean().optional(),
1069
+ sshKeysStale: z5.boolean().optional(),
939
1070
  /** Public port on the host DNATed to the box's SSH while it runs (#49); fixed for the box's life. Absent: none. */
940
- sshPort: z4.number().int().optional()
1071
+ sshPort: z5.number().int().optional()
941
1072
  });
942
1073
  function newBoxId(taken) {
943
1074
  for (; ; ) {
@@ -945,16 +1076,16 @@ function newBoxId(taken) {
945
1076
  if (!taken.has(id)) return id;
946
1077
  }
947
1078
  }
948
- var SnapshotMetaSchema = z4.object({
949
- name: z4.string(),
950
- owner: z4.string(),
1079
+ var SnapshotMetaSchema = z5.object({
1080
+ name: z5.string(),
1081
+ owner: z5.string(),
951
1082
  /** The box it was taken from, as owner/name. */
952
- source: z4.string(),
953
- base: z4.string(),
954
- vcpu: z4.number().int(),
955
- memMib: z4.number().int(),
956
- sizeBytes: z4.number().int(),
957
- createdAt: z4.string()
1083
+ source: z5.string(),
1084
+ base: z5.string(),
1085
+ vcpu: z5.number().int(),
1086
+ memMib: z5.number().int(),
1087
+ sizeBytes: z5.number().int(),
1088
+ createdAt: z5.string()
958
1089
  });
959
1090
  async function ensureLayout(p = paths()) {
960
1091
  for (const dir of [p.home, p.ssh, p.bin, p.assets, p.machines, p.snapshots]) {
@@ -1016,7 +1147,7 @@ function pidAlive(pid) {
1016
1147
  }
1017
1148
 
1018
1149
  // src/app.ts
1019
- import { Hono as Hono5 } from "hono";
1150
+ import { Hono as Hono6 } from "hono";
1020
1151
 
1021
1152
  // ../shared/src/version.ts
1022
1153
  import { createRequire } from "node:module";
@@ -1031,7 +1162,7 @@ function versionOf(srcFileUrl, injected) {
1031
1162
  }
1032
1163
 
1033
1164
  // src/version.ts
1034
- var VERSION = versionOf(import.meta.url, true ? "0.5.5" : void 0);
1165
+ var VERSION = versionOf(import.meta.url, true ? "0.5.6" : void 0);
1035
1166
 
1036
1167
  // src/caller.ts
1037
1168
  import { createHash as createHash2, timingSafeEqual } from "node:crypto";
@@ -1214,7 +1345,12 @@ var MIGRATIONS = [
1214
1345
  disk_gib integer not null,
1215
1346
  by text not null
1216
1347
  );
1217
- create index usage_events_box_at on usage_events (box_id, at);`
1348
+ create index usage_events_box_at on usage_events (box_id, at);`,
1349
+ // The ledger's identity (#42): a control plane keys pulled rows by it, so a re-join is recognised
1350
+ // and a fresh ledger (KVBOX_HOME wiped) restarts its cursor instead of waiting for ids it already saw.
1351
+ `create table ledger (id text not null);
1352
+ insert into ledger (id) values (lower(hex(randomblob(16))));
1353
+ create index usage_events_at on usage_events (at);`
1218
1354
  ];
1219
1355
  var handles = /* @__PURE__ */ new Map();
1220
1356
  function openDb(p = paths()) {
@@ -1277,6 +1413,22 @@ function usageEventsFor(boxId, since, p = paths()) {
1277
1413
  const rows = openDb(p).prepare("select * from usage_events where box_id = ? and at >= ? order by at, id").all(boxId, since);
1278
1414
  return rows.map(fromRaw);
1279
1415
  }
1416
+ var withId = (r) => ({ ...fromRaw(r), id: r.id });
1417
+ function ledgerId(p = paths()) {
1418
+ return openDb(p).prepare("select id from ledger").get().id;
1419
+ }
1420
+ function usageEventsAfter(after, limit, scope, p = paths()) {
1421
+ const db = openDb(p);
1422
+ const rows = scope === null ? db.prepare("select * from usage_events where id > ? order by id limit ?").all(after, limit) : typeof scope === "string" ? db.prepare("select * from usage_events where id > ? and owner = ? order by id limit ?").all(after, scope, limit) : db.prepare("select * from usage_events where id > ? and substr(owner, -length(?)) = ? order by id limit ?").all(after, `@${scope.realm}`, `@${scope.realm}`, limit);
1423
+ return rows.map(withId);
1424
+ }
1425
+ function usageEventsInScope(scope, window, p = paths()) {
1426
+ const rows = openDb(p).prepare(
1427
+ `select * from usage_events where at < ? and (at >= ? or id in (select max(id) from usage_events where at < ? group by box_id))
1428
+ order by at, id`
1429
+ ).all(window.to, window.from, window.from);
1430
+ return rows.filter((r) => inScope(r.owner, scope)).map(withId);
1431
+ }
1280
1432
 
1281
1433
  // src/core/firecracker.ts
1282
1434
  import { Agent, request as request2 } from "undici";
@@ -2105,46 +2257,6 @@ function capacityRoutes() {
2105
2257
  import { Hono as Hono2 } from "hono";
2106
2258
 
2107
2259
  // src/core/usage.ts
2108
- var HOUR_MS = 36e5;
2109
- var round = (n, places) => Math.round(n * 10 ** places) / 10 ** places;
2110
- function computeUsage(events, w) {
2111
- const sorted = [...events].sort((a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0);
2112
- const from = Date.parse(w.from);
2113
- const to = Date.parse(w.to);
2114
- const ms = { running: 0, standby: 0, hibernated: 0, stopped: 0 };
2115
- let vcpuHours = 0;
2116
- let ramGibHours = 0;
2117
- let diskGibHours = 0;
2118
- sorted.forEach((e, i) => {
2119
- const start = Math.max(Date.parse(e.at), from);
2120
- const next = sorted[i + 1];
2121
- const end = Math.min(next === void 0 ? to : Date.parse(next.at), to);
2122
- if (end <= start) return;
2123
- const h = (end - start) / HOUR_MS;
2124
- ms[e.state] += end - start;
2125
- if (e.state === "running") vcpuHours += h * e.vcpu;
2126
- if (e.state === "running" || e.state === "standby") ramGibHours += h * e.memMib / 1024;
2127
- diskGibHours += h * e.diskGib;
2128
- });
2129
- const totals = {
2130
- seconds: {
2131
- running: Math.round(ms.running / 1e3),
2132
- standby: Math.round(ms.standby / 1e3),
2133
- hibernated: Math.round(ms.hibernated / 1e3),
2134
- stopped: Math.round(ms.stopped / 1e3)
2135
- },
2136
- vcpuHours: round(vcpuHours, 3),
2137
- ramGibHours: round(ramGibHours, 3),
2138
- diskGibHours: round(diskGibHours, 3),
2139
- estimatedStops: sorted.filter((e) => e.state === "stopped" && e.by === "server" && e.at >= w.from).length,
2140
- historyFrom: sorted[0]?.at ?? null
2141
- };
2142
- if (w.pricing !== void 0) {
2143
- const amount = vcpuHours * w.pricing.vcpuHour + ramGibHours * w.pricing.ramGibHour + diskGibHours * w.pricing.diskGibHour;
2144
- totals.cost = { amount: round(amount, 4), currency: w.pricing.currency };
2145
- }
2146
- return totals;
2147
- }
2148
2260
  async function machineUsage(ref, since, p = paths()) {
2149
2261
  const box = await getMachine(ref, p);
2150
2262
  const cfg = await loadConfig(p);
@@ -2153,6 +2265,12 @@ async function machineUsage(ref, since, p = paths()) {
2153
2265
  const window = { from: since ?? box.createdAt, to: now };
2154
2266
  return { name: box.name, owner: box.owner, status: box.status, window, ...computeUsage(events, { ...window, pricing: cfg.pricing }) };
2155
2267
  }
2268
+ async function usageReport(scope, window, p = paths()) {
2269
+ const cfg = await loadConfig(p);
2270
+ const live = new Map((await listMachines(scope, p)).map((m) => [m.id, m.status]));
2271
+ const boxes = groupUsage(usageEventsInScope(scope, window, p), window, cfg.pricing, live);
2272
+ return { window, boxes, totals: sumUsage(boxes, cfg.pricing) };
2273
+ }
2156
2274
 
2157
2275
  // src/routes/machines.ts
2158
2276
  function machineRoutes() {
@@ -2350,9 +2468,38 @@ function sshRoutes(opts = {}) {
2350
2468
  return r;
2351
2469
  }
2352
2470
 
2471
+ // src/routes/usage.ts
2472
+ import { Hono as Hono5 } from "hono";
2473
+ var PULL_LIMIT = 1e3;
2474
+ function usageRoutes(opts = {}) {
2475
+ const r = new Hono5();
2476
+ r.get("/", async (c) => {
2477
+ let window;
2478
+ try {
2479
+ window = usageWindow(c.req.query("since"), c.req.query("until"));
2480
+ } catch (err) {
2481
+ throw new KvboxError(err.message);
2482
+ }
2483
+ return c.json(await usageReport(c.get("listScope"), window));
2484
+ });
2485
+ r.get("/events", async (c) => {
2486
+ const after = whole(c.req.query("after") ?? "0", "after");
2487
+ const limit = Math.min(whole(c.req.query("limit") ?? String(PULL_LIMIT), "limit") || PULL_LIMIT, PULL_LIMIT);
2488
+ const scope = opts.controlPlane === void 0 ? null : c.get("listScope");
2489
+ const events = usageEventsAfter(after, limit, scope);
2490
+ return c.json({ ledger: ledgerId(), events, next: events.length === 0 ? after : events[events.length - 1].id });
2491
+ });
2492
+ return r;
2493
+ }
2494
+ function whole(value, name) {
2495
+ const n = Number(value);
2496
+ if (!Number.isInteger(n) || n < 0) throw new KvboxError(`${name}: expected a whole number, got "${value}"`);
2497
+ return n;
2498
+ }
2499
+
2353
2500
  // src/app.ts
2354
2501
  function createApp(opts = {}) {
2355
- const app = new Hono5();
2502
+ const app = new Hono6();
2356
2503
  app.use(resolveCaller(opts));
2357
2504
  app.get("/health", async (c) => {
2358
2505
  const cfg = await loadConfig();
@@ -2371,6 +2518,7 @@ function createApp(opts = {}) {
2371
2518
  app.route("/snapshots", snapshotRoutes());
2372
2519
  app.route("/capacity", capacityRoutes());
2373
2520
  app.route("/ssh", sshRoutes(opts));
2521
+ app.route("/usage", usageRoutes(opts));
2374
2522
  app.notFound((c) => c.json({ error: `no route: ${c.req.method} ${c.req.path}` }, 404));
2375
2523
  app.onError((err, c) => {
2376
2524
  if (err instanceof NotFoundError) return c.json({ error: err.message }, 404);