@netnodeag/kraftwerk 0.64.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +126 -138
  2. package/dist/acp.js +3 -1
  3. package/dist/cli/daemon.d.ts +14 -0
  4. package/dist/cli/daemon.js +60 -0
  5. package/dist/cli/devices.d.ts +14 -0
  6. package/dist/cli/devices.js +53 -0
  7. package/dist/cli/doctor-legacy.js +9 -1
  8. package/dist/cli/doctor.js +17 -30
  9. package/dist/cli/init.js +1 -6
  10. package/dist/cli/kraftwerk.js +8 -3
  11. package/dist/cli/ui.d.ts +9 -0
  12. package/dist/cli/ui.js +47 -23
  13. package/dist/cli/workspaces.js +5 -3
  14. package/dist/client/index.d.ts +17 -0
  15. package/dist/client/index.js +10 -2
  16. package/dist/client/live.d.ts +1 -2
  17. package/dist/client/live.js +4 -2
  18. package/dist/client/routes.d.ts +8 -0
  19. package/dist/client/routes.js +8 -0
  20. package/dist/config.d.ts +18 -55
  21. package/dist/config.js +39 -92
  22. package/dist/core/chat/context.js +1 -1
  23. package/dist/core/chat/pi.js +2 -1
  24. package/dist/core/chat/sessions.d.ts +1 -1
  25. package/dist/core/chat/sessions.js +1 -1
  26. package/dist/core/chat/state.d.ts +2 -0
  27. package/dist/core/chat/state.js +5 -0
  28. package/dist/core/daemon.d.ts +27 -0
  29. package/dist/core/daemon.js +61 -0
  30. package/dist/core/devices.d.ts +35 -0
  31. package/dist/core/devices.js +156 -0
  32. package/dist/core/env.d.ts +16 -0
  33. package/dist/core/env.js +45 -0
  34. package/dist/core/git.d.ts +2 -0
  35. package/dist/core/git.js +20 -4
  36. package/dist/core/instances.d.ts +32 -12
  37. package/dist/core/instances.js +136 -64
  38. package/dist/core/routines.d.ts +6 -0
  39. package/dist/core/routines.js +11 -0
  40. package/dist/core/runner.js +7 -3
  41. package/dist/core/vibeables.d.ts +2 -0
  42. package/dist/core/vibeables.js +17 -5
  43. package/dist/core/workspace.d.ts +13 -0
  44. package/dist/core/workspace.js +25 -0
  45. package/dist/dotenv.d.ts +2 -2
  46. package/dist/dotenv.js +2 -2
  47. package/dist/server/api/devices.d.ts +20 -0
  48. package/dist/server/api/devices.js +55 -0
  49. package/dist/server/api/hub.d.ts +25 -0
  50. package/dist/server/api/hub.js +32 -0
  51. package/dist/server/api/index.d.ts +42 -2
  52. package/dist/server/api/index.js +5 -1
  53. package/dist/server/api/router.d.ts +21 -6
  54. package/dist/server/api/router.js +37 -9
  55. package/dist/server/api/socket.d.ts +15 -1
  56. package/dist/server/api/socket.js +59 -8
  57. package/dist/server/api/workspace.d.ts +2 -0
  58. package/dist/server/api/workspace.js +4 -2
  59. package/dist/server/bind.d.ts +16 -0
  60. package/dist/server/bind.js +25 -0
  61. package/dist/server/cloud.d.ts +5 -1
  62. package/dist/server/cloud.js +89 -56
  63. package/dist/server/hub-context.d.ts +7 -0
  64. package/dist/server/hub-context.js +18 -0
  65. package/dist/server/live.d.ts +2 -0
  66. package/dist/server/live.js +9 -0
  67. package/dist/server/preview.js +2 -1
  68. package/dist/server/server.d.ts +58 -1
  69. package/dist/server/server.js +267 -103
  70. package/dist/server/target.d.ts +20 -0
  71. package/dist/server/target.js +18 -0
  72. package/dist/server/trust.d.ts +41 -0
  73. package/dist/server/trust.js +42 -0
  74. package/package.json +1 -1
  75. package/schema/kraftwerk.schema.json +7 -46
  76. package/web/dist/assets/{dist-Cgcwvf4j.js → dist-1bNkaWWA.js} +1 -1
  77. package/web/dist/assets/dist-BU1SOB5S.js +1 -0
  78. package/web/dist/assets/{dist-CboP0-T1.js → dist-Bk3f-J5U.js} +1 -1
  79. package/web/dist/assets/{dist-2kswkIH2.js → dist-Bx99Puvv.js} +1 -1
  80. package/web/dist/assets/{dist-D2PvEzo7.js → dist-C0M2aITo.js} +1 -1
  81. package/web/dist/assets/{dist-D_BcTzz-.js → dist-C0z19Zge.js} +1 -1
  82. package/web/dist/assets/{dist-36MjITWa.js → dist-COEgBKG6.js} +1 -1
  83. package/web/dist/assets/{dist-Jdx6DRrm.js → dist-CYRrdlae.js} +1 -1
  84. package/web/dist/assets/{dist-DDkd_zrP.js → dist-CbEFnSCD.js} +1 -1
  85. package/web/dist/assets/{dist-BsuA2pgO.js → dist-DCYVKU-H.js} +1 -1
  86. package/web/dist/assets/{dist-B8gPjMPP.js → dist-XyTHWtoN.js} +1 -1
  87. package/web/dist/assets/{editor-ByRUOfrX.js → editor-DPcGvSPy.js} +3 -3
  88. package/web/dist/assets/index-BAYs-2WC.css +2 -0
  89. package/web/dist/assets/index-DKLJdwYE.js +229 -0
  90. package/web/dist/index.html +2 -2
  91. package/dist/cli/tunnel.d.ts +0 -10
  92. package/dist/cli/tunnel.js +0 -280
  93. package/dist/server/access.d.ts +0 -32
  94. package/dist/server/access.js +0 -121
  95. package/web/dist/assets/dist-CVqmJrYd.js +0 -1
  96. package/web/dist/assets/index-BR7HvDzD.js +0 -229
  97. package/web/dist/assets/index-Bra74Nxe.css +0 -2
@@ -6,9 +6,10 @@ import path from "node:path";
6
6
  import { parse } from "yaml";
7
7
  import { resolveWorkspace, vibeablesRootFor } from "../config.js";
8
8
  import { workspaceRoot } from "./context.js";
9
- import { perWorkspace } from "./workspace.js";
9
+ import { perWorkspace, workspaceOpen } from "./workspace.js";
10
10
  import { moveToTrash } from "./trash.js";
11
11
  import { newestMtime } from "./mtime.js";
12
+ import { workspaceEnv } from "./env.js";
12
13
  export const VIBEABLE_CONFIG_FILE = "vibeable.yml";
13
14
  const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
14
15
  export function safeVibeableSlug(slug) {
@@ -353,7 +354,7 @@ async function startDevNow(slug) {
353
354
  detached: process.platform !== "win32",
354
355
  stdio: ["ignore", "pipe", "pipe"],
355
356
  env: {
356
- ...process.env,
357
+ ...workspaceEnv(),
357
358
  PORT: String(port),
358
359
  BASE_PATH: basePath,
359
360
  PUBLIC_URL: basePath,
@@ -371,19 +372,22 @@ async function startDevNow(slug) {
371
372
  pushLog(d, Buffer.from(`spawn failed: ${err.message}\n`));
372
373
  d.exitCode = null;
373
374
  d.ready = false;
374
- broadcast(slug, { type: "dev", dev: devView(slug) });
375
+ if (workspaceOpen())
376
+ broadcast(slug, { type: "dev", dev: devView(slug) });
375
377
  });
376
378
  child.on("exit", (code) => {
377
379
  d.exitCode = code;
378
380
  d.ready = false;
379
381
  pushLog(d, Buffer.from(`[exited with ${code ?? "signal"}]\n`));
380
- broadcast(slug, { type: "dev", dev: devView(slug) });
382
+ // Closing the workspace ends its dev servers: their exit then has nobody to tell.
383
+ if (workspaceOpen())
384
+ broadcast(slug, { type: "dev", dev: devView(slug) });
381
385
  });
382
386
  broadcast(slug, { type: "dev", dev: devView(slug) });
383
387
  // Readiness runs in the background; the pane follows the dev events.
384
388
  void (async () => {
385
389
  const until = Date.now() + READY_TIMEOUT;
386
- while (Date.now() < until && d.exitCode === undefined && devs().get(slug) === d) {
390
+ while (Date.now() < until && d.exitCode === undefined && workspaceOpen() && devs().get(slug) === d) {
387
391
  if (await probe(port)) {
388
392
  d.ready = true;
389
393
  broadcast(slug, { type: "dev", dev: devView(slug) });
@@ -432,3 +436,11 @@ export function disposeAllDevs() {
432
436
  if (d.exitCode === undefined)
433
437
  killGroup(d, "SIGTERM");
434
438
  }
439
+ /** The current workspace's dev servers and file watchers end (the workspace closes). */
440
+ export function disposeWorkspaceDevs() {
441
+ for (const d of devs().values())
442
+ if (d.exitCode === undefined)
443
+ killGroup(d, "SIGTERM");
444
+ for (const slug of [...channels().keys()])
445
+ dropChannel(slug);
446
+ }
@@ -18,19 +18,32 @@
18
18
  export declare class Workspace {
19
19
  readonly root: string;
20
20
  readonly outputDir: string;
21
+ /** Its address on this machine (kraftwerk.yml `slug`, else the folder name), when known. */
22
+ readonly slug?: string;
23
+ /** Its `.env` (parsed, not applied — see env.ts); {} when none was given. */
24
+ readonly env: Record<string, string>;
21
25
  private readonly slots;
22
26
  /** `root` defaults to the parent of the output dir (wrong for nested output dirs — pass it when known). */
23
27
  constructor(opts: {
24
28
  outputDir: string;
25
29
  root?: string;
30
+ slug?: string;
31
+ env?: Record<string, string>;
26
32
  });
27
33
  /** Run `fn` (and everything it starts) in this workspace. */
28
34
  run<T>(fn: () => T): T;
35
+ /** Closed (no longer served): its state is gone and may not come back — see close(). */
36
+ get closed(): boolean;
37
+ private isClosed;
29
38
  slot<T>(key: object, init: () => T): T;
39
+ /** No longer served: sweeps over all workspaces (shutdown, the idle reaper) skip it. Its slots are dropped. */
40
+ close(): void;
30
41
  peek<T>(key: object): T | undefined;
31
42
  }
32
43
  /** The workspace a single-workspace process serves; used outside any `ws.run`. */
33
44
  export declare function setDefaultWorkspace(ws: Workspace): Workspace;
45
+ /** Whether the caller's workspace is still served (a background loop checks before its next round); true outside any. */
46
+ export declare function workspaceOpen(): boolean;
34
47
  export declare function currentWorkspace(): Workspace;
35
48
  export interface PerWorkspace<T> {
36
49
  /** The current workspace's value, created on first use. */
@@ -20,22 +20,42 @@ import path from "node:path";
20
20
  export class Workspace {
21
21
  root;
22
22
  outputDir;
23
+ /** Its address on this machine (kraftwerk.yml `slug`, else the folder name), when known. */
24
+ slug;
25
+ /** Its `.env` (parsed, not applied — see env.ts); {} when none was given. */
26
+ env;
23
27
  slots = new Map();
24
28
  /** `root` defaults to the parent of the output dir (wrong for nested output dirs — pass it when known). */
25
29
  constructor(opts) {
26
30
  this.outputDir = path.resolve(opts.outputDir);
27
31
  this.root = opts.root ? path.resolve(opts.root) : path.dirname(this.outputDir);
32
+ this.slug = opts.slug;
33
+ this.env = opts.env ?? {};
28
34
  known.add(this);
29
35
  }
30
36
  /** Run `fn` (and everything it starts) in this workspace. */
31
37
  run(fn) {
32
38
  return storage.run(this, fn);
33
39
  }
40
+ /** Closed (no longer served): its state is gone and may not come back — see close(). */
41
+ get closed() {
42
+ return this.isClosed;
43
+ }
44
+ isClosed = false;
34
45
  slot(key, init) {
46
+ // Something still running for a closed workspace (a fetch in flight, a heartbeat) must not bring its state back.
47
+ if (this.isClosed)
48
+ throw new Error("workspace closed");
35
49
  if (!this.slots.has(key))
36
50
  this.slots.set(key, init());
37
51
  return this.slots.get(key);
38
52
  }
53
+ /** No longer served: sweeps over all workspaces (shutdown, the idle reaper) skip it. Its slots are dropped. */
54
+ close() {
55
+ this.isClosed = true;
56
+ known.delete(this);
57
+ this.slots.clear();
58
+ }
39
59
  peek(key) {
40
60
  return this.slots.get(key);
41
61
  }
@@ -48,6 +68,11 @@ export function setDefaultWorkspace(ws) {
48
68
  fallback = ws;
49
69
  return ws;
50
70
  }
71
+ /** Whether the caller's workspace is still served (a background loop checks before its next round); true outside any. */
72
+ export function workspaceOpen() {
73
+ const ws = storage.getStore() ?? fallback;
74
+ return !ws?.closed;
75
+ }
51
76
  export function currentWorkspace() {
52
77
  const ws = storage.getStore() ?? fallback;
53
78
  if (!ws)
package/dist/dotenv.d.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * The project's `.env`: KEY=VALUE lines next to kraftwerk.yml, loaded into
3
3
  * this process at CLI start (a commander preAction hook in kraftwerk.ts),
4
4
  * so every kraftwerk process sees them and everything it spawns inherits
5
- * them — the inspector, the chat agents and routines, workflow runs, the
6
- * tunnel (TUNNEL_TOKEN), `requires:` checks.
5
+ * them — the inspector, the chat agents and routines, workflow runs,
6
+ * `requires:` checks.
7
7
  *
8
8
  * A variable already set in the shell wins over the file, the dotenv
9
9
  * convention, with one exception that makes restarts work: a value this
package/dist/dotenv.js CHANGED
@@ -4,8 +4,8 @@ import path from "node:path";
4
4
  * The project's `.env`: KEY=VALUE lines next to kraftwerk.yml, loaded into
5
5
  * this process at CLI start (a commander preAction hook in kraftwerk.ts),
6
6
  * so every kraftwerk process sees them and everything it spawns inherits
7
- * them — the inspector, the chat agents and routines, workflow runs, the
8
- * tunnel (TUNNEL_TOKEN), `requires:` checks.
7
+ * them — the inspector, the chat agents and routines, workflow runs,
8
+ * `requires:` checks.
9
9
  *
10
10
  * A variable already set in the shell wins over the file, the dotenv
11
11
  * convention, with one exception that makes restarts work: a value this
@@ -0,0 +1,20 @@
1
+ import * as z from "zod";
2
+ /** Paired devices: other machines that use this one's kraftwerk (see core/devices.ts). */
3
+ export declare const deviceRoutes: (import("./router.js").Route<"devices.code", {
4
+ code: string;
5
+ expiresAt: string;
6
+ urls: string[];
7
+ }, "/api/devices/code", undefined> | import("./router.js").Route<"devices.list", {
8
+ devices: import("../../core/devices.js").Device[];
9
+ }, "/api/devices", undefined> | import("./router.js").Route<"devices.pair", import("./router.js").Reply<{
10
+ token: string;
11
+ device: import("../../core/devices.js").Device;
12
+ }>, "/api/pair", z.ZodObject<{
13
+ code: z.ZodString;
14
+ name: z.ZodDefault<z.ZodString>;
15
+ }, z.core.$strip>> | import("./router.js").Route<"devices.revoke", {
16
+ ok: boolean;
17
+ }, "/api/devices/:id", undefined> | import("./router.js").Route<"devices.self", {
18
+ trust: "device" | "local" | "none";
19
+ device?: import("../../core/devices.js").Device | undefined;
20
+ }, "/api/devices/self", undefined>)[];
@@ -0,0 +1,55 @@
1
+ import * as z from "zod";
2
+ import { createPairCode, listDevices, redeemPairCode, revokeDevice } from "../../core/devices.js";
3
+ import { networkUrls } from "../bind.js";
4
+ import { DEVICE_COOKIE } from "../trust.js";
5
+ import { ApiError, fail, reply, route } from "./router.js";
6
+ /** Failed pairing attempts per peer, to make guessing a code pointless: a code is 8 characters from 31 and lives 10 minutes. */
7
+ const failures = new Map();
8
+ const MAX_FAILURES = 10;
9
+ const FAILURE_WINDOW_MS = 10 * 60_000;
10
+ function guard(peer) {
11
+ const now = Date.now();
12
+ const recent = (failures.get(peer) ?? []).filter((t) => now - t < FAILURE_WINDOW_MS);
13
+ failures.set(peer, recent);
14
+ if (recent.length >= MAX_FAILURES)
15
+ fail(429, "too many wrong pairing codes — wait a few minutes");
16
+ }
17
+ /** Paired devices: other machines that use this one's kraftwerk (see core/devices.ts). */
18
+ export const deviceRoutes = [
19
+ // Public: an unpaired device trades a code for its token. A browser also gets the token as an
20
+ // HttpOnly cookie, so previews, downloads and the socket work without script access to it.
21
+ route({
22
+ name: "devices.pair",
23
+ method: "POST",
24
+ path: "/api/pair",
25
+ summary: "trade a pairing code {code, name} for a device token",
26
+ access: "public",
27
+ body: z.object({ code: z.string().min(1), name: z.string().default("") }),
28
+ }, async (c) => {
29
+ const peer = c.req?.socket.remoteAddress ?? "socket";
30
+ guard(peer);
31
+ const { code, name } = await c.body();
32
+ try {
33
+ const paired = await redeemPairCode(code, name);
34
+ const secure = c.req?.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
35
+ return reply(200, paired, {
36
+ "set-cookie": `${DEVICE_COOKIE}=${encodeURIComponent(paired.token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=34560000${secure}`,
37
+ });
38
+ }
39
+ catch (err) {
40
+ failures.get(peer)?.push(Date.now()) ?? failures.set(peer, [Date.now()]);
41
+ throw new ApiError(400, err.message);
42
+ }
43
+ }),
44
+ route({ name: "devices.self", method: "GET", path: "/api/devices/self", summary: "how this caller is trusted: this machine or a paired device" }, async (c) => ({
45
+ trust: c.trust.kind,
46
+ ...(c.trust.kind === "device" ? { device: c.trust.device } : {}),
47
+ })),
48
+ route({ name: "devices.list", method: "GET", path: "/api/devices", summary: "every paired device", access: "local" }, async () => ({ devices: await listDevices() })),
49
+ // The code is shown once; the addresses are where a device on the network opens kraftwerk (none while bound to this machine only).
50
+ route({ name: "devices.code", method: "POST", path: "/api/devices/code", summary: "a one-time pairing code, valid 10 minutes", access: "local" }, async (c) => ({
51
+ ...(await createPairCode()),
52
+ urls: networkUrls(c.req?.socket.localPort ?? 0),
53
+ })),
54
+ route({ name: "devices.revoke", method: "DELETE", path: "/api/devices/:id", summary: "unpair a device: its token stops working at once", access: "local" }, async (c) => (await revokeDevice(c.params.id)) ? { ok: true } : fail(404, "no such device")),
55
+ ];
@@ -0,0 +1,25 @@
1
+ import * as z from "zod";
2
+ /** What a hub serves (server.ts Hub): the machine's open workspaces, and opening or closing one. Need no workspace. */
3
+ export declare const hubRoutes: (import("./router.js").Route<"hub.close", {
4
+ ok: boolean;
5
+ }, "/api/hub/close", z.ZodObject<{
6
+ root: z.ZodString;
7
+ }, z.core.$strip>> | import("./router.js").Route<"hub.list", {
8
+ daemon: boolean;
9
+ port: number;
10
+ workspaces: {
11
+ slug: string;
12
+ name: string;
13
+ url: string;
14
+ aliasPort?: number | undefined;
15
+ root?: string | undefined;
16
+ rootLabel?: string | undefined;
17
+ }[];
18
+ }, "/api/hub", undefined> | import("./router.js").Route<"hub.open", {
19
+ slug: string;
20
+ name: string;
21
+ url: string;
22
+ aliasPort?: number | undefined;
23
+ }, "/api/hub/open", z.ZodObject<{
24
+ root: z.ZodString;
25
+ }, z.core.$strip>>)[];
@@ -0,0 +1,32 @@
1
+ import * as z from "zod";
2
+ import { tildify } from "../../core/instances.js";
3
+ import { currentHub } from "../hub-context.js";
4
+ import { fail, route } from "./router.js";
5
+ const Root = z.object({ root: z.string().min(1, "root is required") });
6
+ /** What a hub serves (server.ts Hub): the machine's open workspaces, and opening or closing one. Need no workspace. */
7
+ export const hubRoutes = [
8
+ // The folder is this machine's business; a paired device sees names and addresses.
9
+ route({ name: "hub.list", method: "GET", path: "/api/hub", summary: "the workspaces this kraftwerk serves: slug, name, address" }, async (c) => {
10
+ const hub = currentHub();
11
+ return {
12
+ daemon: hub.daemon,
13
+ port: hub.port,
14
+ workspaces: hub.list().map((o) => ({
15
+ slug: o.slug,
16
+ name: o.name,
17
+ url: o.url,
18
+ ...(o.aliasPort ? { aliasPort: o.aliasPort } : {}),
19
+ ...(c.trust.kind === "local" ? { root: o.root, rootLabel: tildify(o.root) } : {}),
20
+ })),
21
+ };
22
+ }),
23
+ route({ name: "hub.open", method: "POST", path: "/api/hub/open", summary: "serve a workspace {root} next to the others", access: "local", body: Root, errors: 400 }, async (c) => {
24
+ const { root } = await c.body();
25
+ const o = await currentHub().open(root);
26
+ return { slug: o.slug, name: o.name, url: o.url, ...(o.aliasPort ? { aliasPort: o.aliasPort } : {}) };
27
+ }),
28
+ route({ name: "hub.close", method: "POST", path: "/api/hub/close", summary: "stop serving a workspace {root}; the others keep running", access: "local", body: Root }, async (c) => {
29
+ const { root } = await c.body();
30
+ return (await currentHub().close(root)) ? { ok: true } : fail(404, "not open here");
31
+ }),
32
+ ];
@@ -284,7 +284,24 @@ export declare const routes: (Route<"agents.addJournal", {
284
284
  ok: true;
285
285
  }, "/api/chats/:id/task-stop", z.ZodObject<{
286
286
  taskId: z.ZodDefault<z.ZodString>;
287
- }, z.core.$strip>> | Route<"files.delete", {
287
+ }, z.core.$strip>> | Route<"devices.code", {
288
+ code: string;
289
+ expiresAt: string;
290
+ urls: string[];
291
+ }, "/api/devices/code", undefined> | Route<"devices.list", {
292
+ devices: import("../../core/devices.js").Device[];
293
+ }, "/api/devices", undefined> | Route<"devices.pair", import("./router.js").Reply<{
294
+ token: string;
295
+ device: import("../../core/devices.js").Device;
296
+ }>, "/api/pair", z.ZodObject<{
297
+ code: z.ZodString;
298
+ name: z.ZodDefault<z.ZodString>;
299
+ }, z.core.$strip>> | Route<"devices.revoke", {
300
+ ok: boolean;
301
+ }, "/api/devices/:id", undefined> | Route<"devices.self", {
302
+ trust: "device" | "local" | "none";
303
+ device?: import("../../core/devices.js").Device | undefined;
304
+ }, "/api/devices/self", undefined> | Route<"files.delete", {
288
305
  ok: boolean;
289
306
  }, "/api/files", undefined> | Route<"files.list", import("../../core/files.js").FilesListing, "/api/files/list", undefined> | Route<"files.mkdir", import("./router.js").Reply<import("../../core/files.js").FileEntry>, "/api/files/folder", z.ZodObject<{
290
307
  scope: z.ZodOptional<z.ZodString>;
@@ -320,7 +337,29 @@ export declare const routes: (Route<"agents.addJournal", {
320
337
  }>, "/api/git/pull", undefined> | Route<"git.push", import("./router.js").Reply<{
321
338
  ok: boolean;
322
339
  error?: string;
323
- }>, "/api/git/push", undefined> | Route<"git.status", import("../../core/git.js").GitStatus, "/api/git", undefined> | Route<"knowledge.concept", import("../../okf.js").ConceptDetail, "/api/knowledge/:bundle/concept", undefined> | Route<"knowledge.create", import("../../okf.js").BundleInfo, "/api/knowledge", z.ZodObject<{
340
+ }>, "/api/git/push", undefined> | Route<"git.status", import("../../core/git.js").GitStatus, "/api/git", undefined> | Route<"hub.close", {
341
+ ok: boolean;
342
+ }, "/api/hub/close", z.ZodObject<{
343
+ root: z.ZodString;
344
+ }, z.core.$strip>> | Route<"hub.list", {
345
+ daemon: boolean;
346
+ port: number;
347
+ workspaces: {
348
+ slug: string;
349
+ name: string;
350
+ url: string;
351
+ aliasPort?: number | undefined;
352
+ root?: string | undefined;
353
+ rootLabel?: string | undefined;
354
+ }[];
355
+ }, "/api/hub", undefined> | Route<"hub.open", {
356
+ slug: string;
357
+ name: string;
358
+ url: string;
359
+ aliasPort?: number | undefined;
360
+ }, "/api/hub/open", z.ZodObject<{
361
+ root: z.ZodString;
362
+ }, z.core.$strip>> | Route<"knowledge.concept", import("../../okf.js").ConceptDetail, "/api/knowledge/:bundle/concept", undefined> | Route<"knowledge.create", import("../../okf.js").BundleInfo, "/api/knowledge", z.ZodObject<{
324
363
  name: z.ZodDefault<z.ZodString>;
325
364
  }, z.core.$strip>> | Route<"knowledge.delete", {
326
365
  ok: boolean;
@@ -331,6 +370,7 @@ export declare const routes: (Route<"agents.addJournal", {
331
370
  id: z.ZodDefault<z.ZodString>;
332
371
  }, z.core.$strip>> | Route<"meta.get", {
333
372
  workspaceName: string;
373
+ workspaceSlug: string;
334
374
  workspaceIcon: string;
335
375
  workspaceColor: string;
336
376
  workspaceNamed: boolean;
@@ -3,6 +3,8 @@ import { agentRoutes } from "./agents.js";
3
3
  import { attentionRoutes } from "./attention.js";
4
4
  import { channelRoutes } from "./channels.js";
5
5
  import { chatRoutes } from "./chats.js";
6
+ import { deviceRoutes } from "./devices.js";
7
+ import { hubRoutes } from "./hub.js";
6
8
  import { fileRoutes } from "./files.js";
7
9
  import { gitRoutes } from "./git.js";
8
10
  import { knowledgeRoutes } from "./knowledge.js";
@@ -32,8 +34,10 @@ const domainRoutes = [
32
34
  ...fileRoutes,
33
35
  ...gitRoutes,
34
36
  ...vibeableRoutes,
37
+ ...deviceRoutes,
38
+ ...hubRoutes,
35
39
  ];
36
- const protocolRoute = route({ name: "protocol.get", method: "GET", path: "/api/protocol", summary: "the protocol version and every route" }, async () => ({
40
+ const protocolRoute = route({ name: "protocol.get", method: "GET", path: "/api/protocol", summary: "the protocol version and every route", access: "public" }, async () => ({
37
41
  version: PROTOCOL_VERSION,
38
42
  routes: describe(),
39
43
  }));
@@ -1,5 +1,6 @@
1
1
  import type http from "node:http";
2
2
  import * as z from "zod";
3
+ import type { Trust } from "../trust.js";
3
4
  /**
4
5
  * The API's routing: every endpoint is one named route — an RPC-style name
5
6
  * (`projects.get`), an HTTP method + path pattern, and a handler that
@@ -24,14 +25,17 @@ export interface Ctx<B = Record<string, unknown>> {
24
25
  query: URLSearchParams;
25
26
  /** The JSON body (parsed by the route's schema when it has one); an empty body is `{}`, malformed JSON a 400. Read once. */
26
27
  body<T = B>(): Promise<T>;
28
+ /** Who is asking (see trust.ts). */
29
+ trust: Trust;
27
30
  }
28
- /** A result with a status other than 200 (201 created, 409 with a body, …). */
31
+ /** A result with a status other than 200 (201 created, 409 with a body, …), and headers for HTTP (a cookie); the socket ignores them. */
29
32
  export declare class Reply<B> {
30
33
  readonly status: number;
31
34
  readonly body: B;
32
- constructor(status: number, body: B);
35
+ readonly headers: Record<string, string>;
36
+ constructor(status: number, body: B, headers?: Record<string, string>);
33
37
  }
34
- export declare const reply: <B>(status: number, body: B) => Reply<B>;
38
+ export declare const reply: <B>(status: number, body: B, headers?: Record<string, string>) => Reply<B>;
35
39
  /** A refusal with a status; the body is `{error}` unless given. */
36
40
  export declare class ApiError extends Error {
37
41
  readonly status: number;
@@ -53,6 +57,12 @@ export interface RouteDef<N extends string, P extends string = string, S extends
53
57
  upload?: boolean;
54
58
  /** The JSON body's schema: validated before the handler, typed for clients. */
55
59
  body?: S;
60
+ /**
61
+ * Who may call it. Default: anyone trusted (this machine, a paired
62
+ * device). `public`: also unpaired callers (pairing, the protocol).
63
+ * `local`: this machine only (managing devices).
64
+ */
65
+ access?: "public" | "local";
56
66
  }
57
67
  /** What `c.body()` returns: the schema's output, else a loose object. */
58
68
  export type BodyOf<S> = S extends z.ZodType ? z.output<S> : Record<string, unknown>;
@@ -70,17 +80,22 @@ export type PathOf<R> = R extends Route<string, unknown, infer P, z.ZodType | un
70
80
  export declare const statusByMessage: (missing: RegExp) => (message: string) => number;
71
81
  export interface Router {
72
82
  routes: readonly Route[];
73
- handle(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void>;
83
+ handle(req: http.IncomingMessage, res: http.ServerResponse, url: URL, trust: Trust): Promise<void>;
74
84
  /**
75
85
  * Run a route by name without HTTP (the socket): path parameters by name,
76
86
  * `query` and `body` beside them. Raw and upload routes are refused.
77
87
  */
78
- invoke(name: string, input: Record<string, unknown>): Promise<{
88
+ invoke(name: string, input: Record<string, unknown>, trust: Trust): Promise<{
79
89
  status: number;
80
90
  data: unknown;
81
91
  }>;
92
+ /** Why `trust` may not call route `name` ({status, error}), or undefined when it may. */
93
+ allowed(name: string, trust: Trust): {
94
+ status: number;
95
+ error: string;
96
+ } | undefined;
82
97
  }
83
- export declare function json(res: http.ServerResponse, body: unknown, status?: number): void;
98
+ export declare function json(res: http.ServerResponse, body: unknown, status?: number, headers?: Record<string, string>): void;
84
99
  export declare function readBody(req: http.IncomingMessage): Promise<string>;
85
100
  /** A raw upload body (attachments), capped at `max` bytes. */
86
101
  export declare function readRawBody(req: http.IncomingMessage, max: number): Promise<Buffer>;
@@ -1,15 +1,17 @@
1
1
  import * as z from "zod";
2
2
  import { touch } from "../live.js";
3
- /** A result with a status other than 200 (201 created, 409 with a body, …). */
3
+ /** A result with a status other than 200 (201 created, 409 with a body, …), and headers for HTTP (a cookie); the socket ignores them. */
4
4
  export class Reply {
5
5
  status;
6
6
  body;
7
- constructor(status, body) {
7
+ headers;
8
+ constructor(status, body, headers = {}) {
8
9
  this.status = status;
9
10
  this.body = body;
11
+ this.headers = headers;
10
12
  }
11
13
  }
12
- export const reply = (status, body) => new Reply(status, body);
14
+ export const reply = (status, body, headers) => new Reply(status, body, headers);
13
15
  /** A refusal with a status; the body is `{error}` unless given. */
14
16
  export class ApiError extends Error {
15
17
  status;
@@ -37,6 +39,16 @@ function validate(route, raw) {
37
39
  }
38
40
  /** Maps an error message to a status by the usual wording: "no <thing>" is 404, "… are off" 409, else 400. */
39
41
  export const statusByMessage = (missing) => (message) => missing.test(message) ? 404 : /are off/.test(message) ? 409 : 400;
42
+ /** Why a caller may not use a route, or undefined when it may. */
43
+ function refusal(route, trust) {
44
+ if (route.access === "public")
45
+ return undefined;
46
+ if (trust.kind === "none")
47
+ return new ApiError(401, "pair this device first", { error: "pair this device first", pair: true });
48
+ if (route.access === "local" && trust.kind !== "local")
49
+ return new ApiError(403, "only on the machine kraftwerk runs on");
50
+ return undefined;
51
+ }
40
52
  /** What a thrown error becomes: an ApiError's status and body, else the route's mapping. */
41
53
  function failure(route, err) {
42
54
  if (err instanceof ApiError)
@@ -44,11 +56,11 @@ function failure(route, err) {
44
56
  const message = err.message;
45
57
  return { status: typeof route.errors === "function" ? route.errors(message) : (route.errors ?? 500), data: { error: message } };
46
58
  }
47
- export function json(res, body, status = 200) {
59
+ export function json(res, body, status = 200, headers = {}) {
48
60
  // A late error on an SSE response must not try to write headers again.
49
61
  if (res.headersSent)
50
62
  return void res.end();
51
- res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
63
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
52
64
  res.end(JSON.stringify(body));
53
65
  }
54
66
  export function readBody(req) {
@@ -119,12 +131,16 @@ export function createRouter(routes) {
119
131
  };
120
132
  return {
121
133
  routes,
122
- async handle(req, res, url) {
134
+ async handle(req, res, url, trust) {
123
135
  const seg = url.pathname.split("/").filter(Boolean);
124
136
  const found = match(req.method ?? "GET", seg);
137
+ // An unpaired caller learns nothing about which routes exist.
125
138
  if (!found)
126
- return json(res, { error: "not found" }, 404);
139
+ return trust.kind === "none" ? json(res, { error: "pair this device first", pair: true }, 401) : json(res, { error: "not found" }, 404);
127
140
  const { route } = found;
141
+ const refused = refusal(route, trust);
142
+ if (refused)
143
+ return json(res, refused.body, refused.status);
128
144
  let bodyRead;
129
145
  try {
130
146
  const params = Object.fromEntries(Object.entries(found.params).map(([k, v]) => [k, decode(v)]));
@@ -133,6 +149,7 @@ export function createRouter(routes) {
133
149
  res,
134
150
  url,
135
151
  params,
152
+ trust,
136
153
  query: url.searchParams,
137
154
  body: () => (bodyRead ??= readBody(req).then((raw) => {
138
155
  if (!raw.trim())
@@ -153,7 +170,7 @@ export function createRouter(routes) {
153
170
  if (route.raw)
154
171
  return;
155
172
  if (result instanceof Reply)
156
- return json(res, result.body, result.status);
173
+ return json(res, result.body, result.status, result.headers);
157
174
  return json(res, result);
158
175
  }
159
176
  catch (err) {
@@ -161,10 +178,20 @@ export function createRouter(routes) {
161
178
  return json(res, f.data, f.status);
162
179
  }
163
180
  },
164
- async invoke(name, input) {
181
+ allowed(name, trust) {
182
+ const route = byName.get(name);
183
+ if (!route)
184
+ return { status: 404, error: `no route ${name}` };
185
+ const refused = refusal(route, trust);
186
+ return refused ? { status: refused.status, error: refused.message } : undefined;
187
+ },
188
+ async invoke(name, input, trust) {
165
189
  const route = byName.get(name);
166
190
  if (!route)
167
191
  return { status: 404, data: { error: `no route ${name}` } };
192
+ const refused = refusal(route, trust);
193
+ if (refused)
194
+ return { status: refused.status, data: refused.body };
168
195
  if (route.raw || route.upload)
169
196
  return { status: 400, data: { error: `${name} is HTTP only` } };
170
197
  try {
@@ -183,6 +210,7 @@ export function createRouter(routes) {
183
210
  url: new URL(`http://invoke${route.path}`),
184
211
  params,
185
212
  query,
213
+ trust,
186
214
  body: async () => validate(route, input.body ?? {}),
187
215
  };
188
216
  const result = await route.handle(c);
@@ -1,3 +1,17 @@
1
1
  import type { Workspace } from "../../core/workspace.js";
2
+ import type { Hub } from "../server.js";
3
+ import type { Trust } from "../trust.js";
2
4
  import type { Socket } from "../ws.js";
3
- export declare function serveSocket(conn: Socket, workspace: Workspace, version: string): void;
5
+ /**
6
+ * `recheck` re-derives the connection's trust (a device's token may have
7
+ * been revoked since it connected): a device whose answer is no longer
8
+ * `device` is disconnected at once (close code 4401).
9
+ */
10
+ /**
11
+ * `workspaceOf` finds the workspace a message is for: its `ws` (a slug), or
12
+ * the connection's own (the one the socket was opened for) when it names
13
+ * none. A daemon's socket opened at its root has no workspace of its own:
14
+ * there, a message without `ws` reaches only the machine's routes (hub,
15
+ * devices, protocol).
16
+ */
17
+ export declare function serveSocket(conn: Socket, workspaceOf: (slug?: string) => Workspace | undefined, version: string, trust: Trust, recheck: () => Promise<Trust>, hub: Hub): void;