@kvbox/server 0.5.5 → 0.5.7

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,321 @@ 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()
473
+ });
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
+ })
484
+ });
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()
370
501
  });
371
- var ApiErrorSchema = z2.object({
372
- error: z2.string()
502
+ var ApiErrorSchema = z3.object({
503
+ error: z3.string()
373
504
  });
374
- var CloudUserSchema = z2.object({
505
+ var ACTIVATED_BY = ["card", "admin"];
506
+ var ActivatedBySchema = z3.enum(ACTIVATED_BY);
507
+ var CloudUserSchema = z3.object({
375
508
  /** The handle: what hosts file boxes under (`<username>@<control plane>`). Lowercase, no `@` or `/`. */
376
- username: z2.string(),
509
+ username: z3.string(),
377
510
  /** Display name, free text ("Alexandru Ghiura"). */
378
- name: z2.string(),
511
+ name: z3.string(),
379
512
  /** Login identity, unique. */
380
- email: z2.string(),
381
- role: z2.enum(["admin", "user"]),
382
- createdAt: z2.string()
513
+ email: z3.string(),
514
+ role: z3.enum(["admin", "user"]),
515
+ createdAt: z3.string(),
516
+ /**
517
+ * False until a card is on file or an admin activates the account (#74): then only `/me` and
518
+ * `/logout` answer. Admins are never gated. A control plane older than #74 does not send these and
519
+ * gates nobody, hence the defaults: a newer CLI must still parse its answers.
520
+ */
521
+ active: z3.boolean().default(true),
522
+ activatedBy: ActivatedBySchema.nullable().default(null)
383
523
  });
384
524
  var ORG_ROLES = ["owner", "admin", "member"];
385
- var OrgRoleSchema = z2.enum(ORG_ROLES);
386
- var CloudOrgSchema = z2.object({
387
- slug: z2.string(),
525
+ var OrgRoleSchema = z3.enum(ORG_ROLES);
526
+ var CloudOrgSchema = z3.object({
527
+ slug: z3.string(),
388
528
  /** Display name; the slug never changes, this may. */
389
- name: z2.string(),
390
- createdAt: z2.string()
529
+ name: z3.string(),
530
+ createdAt: z3.string(),
531
+ /** An org pays for itself (#74): off until it has a card or an admin activates it; its boxes are 402. Defaults as on CloudUser. */
532
+ active: z3.boolean().default(true),
533
+ activatedBy: ActivatedBySchema.nullable().default(null)
391
534
  });
392
535
  var OrgMembershipSchema = CloudOrgSchema.extend({ role: OrgRoleSchema });
393
- var OrgMemberSchema = z2.object({
394
- username: z2.string(),
395
- name: z2.string(),
536
+ var OrgMemberSchema = z3.object({
537
+ username: z3.string(),
538
+ name: z3.string(),
396
539
  role: OrgRoleSchema,
397
- addedAt: z2.string()
540
+ addedAt: z3.string()
398
541
  });
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)
542
+ var OrgDetailSchema = CloudOrgSchema.extend({ members: z3.array(OrgMemberSchema) });
543
+ var CreateOrgRequestSchema = z3.object({
544
+ slug: z3.string().min(1),
545
+ name: z3.string().min(1)
403
546
  });
404
- var RenameOrgRequestSchema = z2.object({ name: z2.string().min(1) });
405
- var AddMemberRequestSchema = z2.object({
406
- username: z2.string().min(1),
547
+ var RenameOrgRequestSchema = z3.object({ name: z3.string().min(1) });
548
+ var AddMemberRequestSchema = z3.object({
549
+ username: z3.string().min(1),
407
550
  role: OrgRoleSchema.default("member")
408
551
  });
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()
552
+ var SetRoleRequestSchema = z3.object({ role: OrgRoleSchema });
553
+ var MeResponseSchema = CloudUserSchema.extend({ orgs: z3.array(OrgMembershipSchema) });
554
+ var BalanceSchema = z3.object({
555
+ slug: z3.string(),
556
+ balance: z3.number(),
557
+ currency: z3.string(),
558
+ topUps: z3.number(),
559
+ spent: z3.number(),
560
+ /** The first moment usage counts from: the slug's first top-up; null when it has none. */
561
+ since: z3.string().nullable(),
562
+ /** When the slug ran out and its boxes were hibernated (the `sweep-credit` job); null in good standing. Absent from a control plane older than this field. */
563
+ suspendedAt: z3.string().nullable().default(null)
415
564
  });
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)
565
+ var TopUpRequestSchema = z3.object({
566
+ slug: z3.string().min(1),
567
+ /** In the control plane's currency; negative corrects a mistake. */
568
+ amount: z3.number().finite(),
569
+ note: z3.string().max(200).optional()
570
+ });
571
+ var TopUpSchema = z3.object({
572
+ id: z3.string(),
573
+ slug: z3.string(),
574
+ amount: z3.number(),
575
+ currency: z3.string(),
576
+ source: z3.enum(["admin", "card", "trial"]),
577
+ note: z3.string().nullable(),
578
+ at: z3.string()
579
+ });
580
+ var LoginRequestSchema = z3.object({
581
+ email: z3.string().min(1),
582
+ password: z3.string().min(1),
583
+ publicKey: z3.string().optional()
421
584
  });
422
- var LoginResponseSchema = z2.object({
423
- token: z2.string(),
585
+ var SignupRequestSchema = z3.object({
586
+ username: z3.string().min(1),
587
+ name: z3.string().min(1),
588
+ email: z3.string().min(1),
589
+ password: z3.string().min(1)
590
+ });
591
+ var LoginResponseSchema = z3.object({
592
+ token: z3.string(),
424
593
  user: CloudUserSchema,
425
- sshCert: z2.string().optional(),
426
- sshCertExpires: z2.string().optional(),
594
+ sshCert: z3.string().optional(),
595
+ sshCertExpires: z3.string().optional(),
427
596
  /** 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()
597
+ sshCertPrincipals: z3.array(z3.string()).optional()
429
598
  });
430
- var SshCertRequestSchema = z2.object({
431
- publicKey: z2.string().min(1)
599
+ var SshCertRequestSchema = z3.object({
600
+ publicKey: z3.string().min(1)
432
601
  });
433
- var SshCertResponseSchema = z2.object({
434
- cert: z2.string(),
435
- expires: z2.string(),
436
- principals: z2.array(z2.string())
602
+ var SshCertResponseSchema = z3.object({
603
+ cert: z3.string(),
604
+ expires: z3.string(),
605
+ principals: z3.array(z3.string())
437
606
  });
438
- var SshCaResponseSchema = z2.object({
439
- publicKey: z2.string(),
607
+ var SshCaResponseSchema = z3.object({
608
+ publicKey: z3.string(),
440
609
  /** 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()
610
+ retired: z3.array(z3.object({ publicKey: z3.string(), retiredAt: z3.string() })).optional()
442
611
  });
443
- var HostSshCaResponseSchema = z2.object({
444
- publicKey: z2.string()
612
+ var HostSshCaResponseSchema = z3.object({
613
+ publicKey: z3.string()
445
614
  });
446
- var CloudCaSchema = z2.object({
447
- controlPlane: z2.string(),
448
- publicKey: z2.string(),
449
- fingerprint: z2.string()
615
+ var CloudCaSchema = z3.object({
616
+ controlPlane: z3.string(),
617
+ publicKey: z3.string(),
618
+ fingerprint: z3.string()
450
619
  });
451
- var TrustCaRequestSchema = z2.object({
452
- publicKey: z2.string().min(1)
620
+ var TrustCaRequestSchema = z3.object({
621
+ publicKey: z3.string().min(1)
453
622
  });
454
- var SshKeysAppliedSchema = z2.enum(["now", "start", "wake", "resume", "later"]);
455
- var TrustCaReportSchema = z2.object({
456
- publicKey: z2.string(),
457
- fingerprint: z2.string(),
623
+ var SshKeysAppliedSchema = z3.enum(["now", "start", "wake", "resume", "later"]);
624
+ var TrustCaReportSchema = z3.object({
625
+ publicKey: z3.string(),
626
+ fingerprint: z3.string(),
458
627
  /** The guest trusts the CA now. */
459
- updated: z2.array(z2.string()),
628
+ updated: z3.array(z3.string()),
460
629
  /** 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() })),
630
+ pending: z3.array(z3.object({ box: z3.string(), when: SshKeysAppliedSchema, why: z3.string().optional() })),
631
+ failed: z3.array(z3.object({ box: z3.string(), error: z3.string() })),
463
632
  /** Local boxes, untouched: they trust the host's key only. */
464
- local: z2.number().int()
633
+ local: z3.number().int()
465
634
  });
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)
635
+ var HostJoinRequestSchema = z3.object({
636
+ token: z3.string().min(1),
637
+ name: z3.string().min(1),
638
+ url: z3.string().url(),
639
+ fingerprint: z3.string().regex(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/),
640
+ publicAddress: z3.string().min(1)
472
641
  });
473
- var HostJoinResponseSchema = z2.object({
474
- hostSecret: z2.string(),
475
- controlPlane: z2.object({ name: z2.string() })
642
+ var HostJoinResponseSchema = z3.object({
643
+ hostSecret: z3.string(),
644
+ controlPlane: z3.object({ name: z3.string() })
476
645
  });
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(),
646
+ var CloudHostSchema = z3.object({
647
+ name: z3.string(),
648
+ url: z3.string(),
649
+ publicAddress: z3.string(),
650
+ joinedAt: z3.string(),
651
+ lastSeenAt: z3.string().nullable(),
483
652
  capacity: CapacitySchema.nullable()
484
653
  });
485
654
 
@@ -809,24 +978,24 @@ function macFor(ip) {
809
978
  import { chmod as chmod3, mkdir as mkdir2, rm, writeFile as writeFile2 } from "node:fs/promises";
810
979
 
811
980
  // ../shared/src/relay.ts
812
- import { z as z3 } from "zod";
981
+ import { z as z4 } from "zod";
813
982
  var RELAY_TUNNEL_GATEWAY = "10.77.0.1";
814
983
  var IPV4_RE = /^(\d{1,3})(\.\d{1,3}){3}$/;
815
984
  var WG_KEY_RE = /^[A-Za-z0-9+/]{43}=$/;
816
- var RelayJoinSchema = z3.object({
817
- v: z3.literal(1),
985
+ var RelayJoinSchema = z4.object({
986
+ v: z4.literal(1),
818
987
  /** How the relay lists this host. */
819
- name: z3.string().regex(/^[a-z0-9][a-z0-9-]{0,30}$/),
988
+ name: z4.string().regex(/^[a-z0-9][a-z0-9-]{0,30}$/),
820
989
  /** `<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}$/),
990
+ relay: z4.string().regex(/^(\d{1,3})(\.\d{1,3}){3}:\d{1,5}$/),
822
991
  /** The relay's WireGuard public key. */
823
- relayKey: z3.string().regex(WG_KEY_RE),
992
+ relayKey: z4.string().regex(WG_KEY_RE),
824
993
  /** This host's address inside the tunnel. */
825
- tunnelIp: z3.string().regex(IPV4_RE),
994
+ tunnelIp: z4.string().regex(IPV4_RE),
826
995
  /** This host's WireGuard private key, generated on the relay, kept nowhere else. */
827
- privateKey: z3.string().regex(WG_KEY_RE),
996
+ privateKey: z4.string().regex(WG_KEY_RE),
828
997
  /** 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)
998
+ publicIp: z4.string().regex(IPV4_RE)
830
999
  });
831
1000
  function decodeRelayJoin(text) {
832
1001
  let parsed;
@@ -900,44 +1069,44 @@ import { createAdaptorServer } from "@hono/node-server";
900
1069
  // src/core/state.ts
901
1070
  import { randomBytes } from "node:crypto";
902
1071
  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({
1072
+ import { z as z5 } from "zod";
1073
+ var MachineMetaSchema = z5.object({
905
1074
  /** 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(),
1075
+ id: z5.string(),
1076
+ name: z5.string(),
908
1077
  /** 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(),
1078
+ owner: z5.string(),
1079
+ ip: z5.string(),
1080
+ mac: z5.string(),
1081
+ pid: z5.number().int().nullable(),
913
1082
  // null: stopped on purpose, disk kept
914
- vcpu: z4.number().int(),
915
- memMib: z4.number().int(),
916
- createdAt: z4.string(),
917
- source: z4.string(),
1083
+ vcpu: z5.number().int(),
1084
+ memMib: z5.number().int(),
1085
+ createdAt: z5.string(),
1086
+ source: z5.string(),
918
1087
  /** Base image version (assets/rootfs-<version>.ext4) the box's overlay stacks on. */
919
- base: z4.string(),
1088
+ base: z5.string(),
920
1089
  /**
921
1090
  * Snapshots taken since this VM started (boot or restore). Firecracker resets its dirty-page bitmap at
922
1091
  * each one, so when this is > 0 the next diff is only standalone merged onto `mem.base`; if that file
923
1092
  * is missing the lineage is broken and the next snapshot has to be a full dump. Absent: 0.
924
1093
  */
925
- memSnapshots: z4.number().int().optional(),
1094
+ memSnapshots: z5.number().int().optional(),
926
1095
  /** `kvbox pause`: Firecracker is alive but the VM is frozen (standby). Absent: not paused. */
927
- paused: z4.boolean().optional(),
1096
+ paused: z5.boolean().optional(),
928
1097
  /**
929
1098
  * authorized_keys lines the box gets on top of the host's own key, as the control plane sent them
930
1099
  * at `new` (#49). kvbox-init rewrites authorized_keys from the kernel cmdline at every boot, so
931
1100
  * `start` has to send the same lines again. Absent: host key only.
932
1101
  */
933
- sshKeys: z4.array(z4.string()).optional(),
1102
+ sshKeys: z5.array(z5.string()).optional(),
934
1103
  /**
935
1104
  * The guest's authorized_keys may not match `sshKeys`: they changed while the box was not running,
936
1105
  * or its agent could not take them (#55, #63). Cleared by a push through kvbox-agent or a boot.
937
1106
  */
938
- sshKeysStale: z4.boolean().optional(),
1107
+ sshKeysStale: z5.boolean().optional(),
939
1108
  /** 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()
1109
+ sshPort: z5.number().int().optional()
941
1110
  });
942
1111
  function newBoxId(taken) {
943
1112
  for (; ; ) {
@@ -945,16 +1114,16 @@ function newBoxId(taken) {
945
1114
  if (!taken.has(id)) return id;
946
1115
  }
947
1116
  }
948
- var SnapshotMetaSchema = z4.object({
949
- name: z4.string(),
950
- owner: z4.string(),
1117
+ var SnapshotMetaSchema = z5.object({
1118
+ name: z5.string(),
1119
+ owner: z5.string(),
951
1120
  /** 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()
1121
+ source: z5.string(),
1122
+ base: z5.string(),
1123
+ vcpu: z5.number().int(),
1124
+ memMib: z5.number().int(),
1125
+ sizeBytes: z5.number().int(),
1126
+ createdAt: z5.string()
958
1127
  });
959
1128
  async function ensureLayout(p = paths()) {
960
1129
  for (const dir of [p.home, p.ssh, p.bin, p.assets, p.machines, p.snapshots]) {
@@ -1016,7 +1185,7 @@ function pidAlive(pid) {
1016
1185
  }
1017
1186
 
1018
1187
  // src/app.ts
1019
- import { Hono as Hono5 } from "hono";
1188
+ import { Hono as Hono6 } from "hono";
1020
1189
 
1021
1190
  // ../shared/src/version.ts
1022
1191
  import { createRequire } from "node:module";
@@ -1031,7 +1200,7 @@ function versionOf(srcFileUrl, injected) {
1031
1200
  }
1032
1201
 
1033
1202
  // src/version.ts
1034
- var VERSION = versionOf(import.meta.url, true ? "0.5.5" : void 0);
1203
+ var VERSION = versionOf(import.meta.url, true ? "0.5.7" : void 0);
1035
1204
 
1036
1205
  // src/caller.ts
1037
1206
  import { createHash as createHash2, timingSafeEqual } from "node:crypto";
@@ -1214,7 +1383,12 @@ var MIGRATIONS = [
1214
1383
  disk_gib integer not null,
1215
1384
  by text not null
1216
1385
  );
1217
- create index usage_events_box_at on usage_events (box_id, at);`
1386
+ create index usage_events_box_at on usage_events (box_id, at);`,
1387
+ // The ledger's identity (#42): a control plane keys pulled rows by it, so a re-join is recognised
1388
+ // and a fresh ledger (KVBOX_HOME wiped) restarts its cursor instead of waiting for ids it already saw.
1389
+ `create table ledger (id text not null);
1390
+ insert into ledger (id) values (lower(hex(randomblob(16))));
1391
+ create index usage_events_at on usage_events (at);`
1218
1392
  ];
1219
1393
  var handles = /* @__PURE__ */ new Map();
1220
1394
  function openDb(p = paths()) {
@@ -1277,6 +1451,22 @@ function usageEventsFor(boxId, since, p = paths()) {
1277
1451
  const rows = openDb(p).prepare("select * from usage_events where box_id = ? and at >= ? order by at, id").all(boxId, since);
1278
1452
  return rows.map(fromRaw);
1279
1453
  }
1454
+ var withId = (r) => ({ ...fromRaw(r), id: r.id });
1455
+ function ledgerId(p = paths()) {
1456
+ return openDb(p).prepare("select id from ledger").get().id;
1457
+ }
1458
+ function usageEventsAfter(after, limit, scope, p = paths()) {
1459
+ const db = openDb(p);
1460
+ 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);
1461
+ return rows.map(withId);
1462
+ }
1463
+ function usageEventsInScope(scope, window, p = paths()) {
1464
+ const rows = openDb(p).prepare(
1465
+ `select * from usage_events where at < ? and (at >= ? or id in (select max(id) from usage_events where at < ? group by box_id))
1466
+ order by at, id`
1467
+ ).all(window.to, window.from, window.from);
1468
+ return rows.filter((r) => inScope(r.owner, scope)).map(withId);
1469
+ }
1280
1470
 
1281
1471
  // src/core/firecracker.ts
1282
1472
  import { Agent, request as request2 } from "undici";
@@ -2105,46 +2295,6 @@ function capacityRoutes() {
2105
2295
  import { Hono as Hono2 } from "hono";
2106
2296
 
2107
2297
  // 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
2298
  async function machineUsage(ref, since, p = paths()) {
2149
2299
  const box = await getMachine(ref, p);
2150
2300
  const cfg = await loadConfig(p);
@@ -2153,6 +2303,12 @@ async function machineUsage(ref, since, p = paths()) {
2153
2303
  const window = { from: since ?? box.createdAt, to: now };
2154
2304
  return { name: box.name, owner: box.owner, status: box.status, window, ...computeUsage(events, { ...window, pricing: cfg.pricing }) };
2155
2305
  }
2306
+ async function usageReport(scope, window, p = paths()) {
2307
+ const cfg = await loadConfig(p);
2308
+ const live = new Map((await listMachines(scope, p)).map((m) => [m.id, m.status]));
2309
+ const boxes = groupUsage(usageEventsInScope(scope, window, p), window, cfg.pricing, live);
2310
+ return { window, boxes, totals: sumUsage(boxes, cfg.pricing) };
2311
+ }
2156
2312
 
2157
2313
  // src/routes/machines.ts
2158
2314
  function machineRoutes() {
@@ -2350,9 +2506,38 @@ function sshRoutes(opts = {}) {
2350
2506
  return r;
2351
2507
  }
2352
2508
 
2509
+ // src/routes/usage.ts
2510
+ import { Hono as Hono5 } from "hono";
2511
+ var PULL_LIMIT = 1e3;
2512
+ function usageRoutes(opts = {}) {
2513
+ const r = new Hono5();
2514
+ r.get("/", async (c) => {
2515
+ let window;
2516
+ try {
2517
+ window = usageWindow(c.req.query("since"), c.req.query("until"));
2518
+ } catch (err) {
2519
+ throw new KvboxError(err.message);
2520
+ }
2521
+ return c.json(await usageReport(c.get("listScope"), window));
2522
+ });
2523
+ r.get("/events", async (c) => {
2524
+ const after = whole(c.req.query("after") ?? "0", "after");
2525
+ const limit = Math.min(whole(c.req.query("limit") ?? String(PULL_LIMIT), "limit") || PULL_LIMIT, PULL_LIMIT);
2526
+ const scope = opts.controlPlane === void 0 ? null : c.get("listScope");
2527
+ const events = usageEventsAfter(after, limit, scope);
2528
+ return c.json({ ledger: ledgerId(), events, next: events.length === 0 ? after : events[events.length - 1].id });
2529
+ });
2530
+ return r;
2531
+ }
2532
+ function whole(value, name) {
2533
+ const n = Number(value);
2534
+ if (!Number.isInteger(n) || n < 0) throw new KvboxError(`${name}: expected a whole number, got "${value}"`);
2535
+ return n;
2536
+ }
2537
+
2353
2538
  // src/app.ts
2354
2539
  function createApp(opts = {}) {
2355
- const app = new Hono5();
2540
+ const app = new Hono6();
2356
2541
  app.use(resolveCaller(opts));
2357
2542
  app.get("/health", async (c) => {
2358
2543
  const cfg = await loadConfig();
@@ -2371,6 +2556,7 @@ function createApp(opts = {}) {
2371
2556
  app.route("/snapshots", snapshotRoutes());
2372
2557
  app.route("/capacity", capacityRoutes());
2373
2558
  app.route("/ssh", sshRoutes(opts));
2559
+ app.route("/usage", usageRoutes(opts));
2374
2560
  app.notFound((c) => c.json({ error: `no route: ${c.req.method} ${c.req.path}` }, 404));
2375
2561
  app.onError((err, c) => {
2376
2562
  if (err instanceof NotFoundError) return c.json({ error: err.message }, 404);