@netnodeag/kraftwerk 0.77.0 → 0.78.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 (50) hide show
  1. package/dist/cli/invite.d.ts +2 -0
  2. package/dist/cli/invite.js +48 -0
  3. package/dist/cli/kraftwerk.js +3 -0
  4. package/dist/client/relay.d.ts +40 -0
  5. package/dist/client/relay.js +35 -0
  6. package/dist/client/routes.d.ts +1 -0
  7. package/dist/client/routes.js +1 -0
  8. package/dist/core/chat/channel-turns.d.ts +2 -2
  9. package/dist/core/chat/channel-turns.js +2 -2
  10. package/dist/core/chat/sessions.d.ts +20 -3
  11. package/dist/core/chat/sessions.js +22 -7
  12. package/dist/core/chat/types.d.ts +21 -0
  13. package/dist/core/cloud.d.ts +38 -1
  14. package/dist/core/cloud.js +4 -0
  15. package/dist/core/routines.d.ts +22 -2
  16. package/dist/core/routines.js +26 -0
  17. package/dist/server/api/chats.js +45 -8
  18. package/dist/server/api/cloud.d.ts +16 -2
  19. package/dist/server/api/cloud.js +15 -2
  20. package/dist/server/api/devices.d.ts +7 -1
  21. package/dist/server/api/devices.js +3 -2
  22. package/dist/server/api/index.d.ts +20 -1
  23. package/dist/server/api/router.d.ts +3 -2
  24. package/dist/server/api/router.js +12 -0
  25. package/dist/server/cloud.d.ts +42 -3
  26. package/dist/server/cloud.js +77 -2
  27. package/dist/server/members.d.ts +87 -0
  28. package/dist/server/members.js +117 -0
  29. package/dist/server/relay.d.ts +8 -1
  30. package/dist/server/relay.js +45 -2
  31. package/dist/server/server.js +7 -0
  32. package/dist/server/trust.d.ts +16 -0
  33. package/dist/server/trust.js +12 -1
  34. package/package.json +1 -1
  35. package/web/dist/assets/{dist-BGHweZKi.js → dist-B-yVL5lk.js} +1 -1
  36. package/web/dist/assets/{dist-BVPxOqcx.js → dist-B9EMB1Ie.js} +1 -1
  37. package/web/dist/assets/{dist-DT2Tjvwx.js → dist-BTc878j7.js} +1 -1
  38. package/web/dist/assets/{dist-DoD3qX1P.js → dist-C90kUzps.js} +1 -1
  39. package/web/dist/assets/{dist-DCFREMXk.js → dist-CpLdAqFQ.js} +1 -1
  40. package/web/dist/assets/{dist-CXoAMU1b.js → dist-Crnb8VVO.js} +1 -1
  41. package/web/dist/assets/{dist-FFA4-S6d.js → dist-D0rjnN8S.js} +1 -1
  42. package/web/dist/assets/{dist-yGx0qYbw.js → dist-D4SDXoGw.js} +1 -1
  43. package/web/dist/assets/{dist-DdA5PoVJ.js → dist-DHRhdODt.js} +1 -1
  44. package/web/dist/assets/{dist-CCNyRSAA.js → dist-DQbifCX0.js} +1 -1
  45. package/web/dist/assets/dist-lSJ-O7kT.js +1 -0
  46. package/web/dist/assets/{editor-CyWapLQr.js → editor-DHAdW1g_.js} +3 -3
  47. package/web/dist/assets/{index-CNXcNoKJ.js → index-D7JVAjll.js} +4 -4
  48. package/web/dist/index.html +1 -1
  49. package/web/dist-remote/sw.js +1 -1
  50. package/web/dist/assets/dist-zjUUP5a9.js +0 -1
@@ -2,8 +2,9 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import * as z from "zod";
4
4
  import { getAgent } from "../../core/agents.js";
5
- import { answerElicitation, cancelChat, createChat, deleteChat, forkChat, getChat, listAgentSessionsFor, listChats, postMessage, renameChat, resetSession, resolvePermission, setChatConfig, setChatProject, setChatVibeable, steerChat, stopChatTask, subscribeChat, } from "../../core/chat/sessions.js";
6
- import { attachmentPath, saveAttachment } from "../../core/chat/store.js";
5
+ import { answerElicitation, cancelChat, createChat, deleteChat, forkChat, getChat, listAgentSessionsFor, listChats, localPerson, postMessage, renameChat, resetSession, resolvePermission, setChatConfig, setChatProject, setChatVibeable, steerChat, stopChatTask, subscribeChat, } from "../../core/chat/sessions.js";
6
+ import { attachmentPath, readMeta, saveAttachment } from "../../core/chat/store.js";
7
+ import { remoteCaller } from "../trust.js";
7
8
  import { getProject } from "../../core/projects.js";
8
9
  import { MIME } from "./mime.js";
9
10
  import { projectErrors } from "./projects.js";
@@ -75,9 +76,39 @@ async function projectSlug(slug) {
75
76
  fail(404, "project not found");
76
77
  return found.slug;
77
78
  }
79
+ /** Who a chat or message is signed with: a member session's member, else this machine's person. */
80
+ const personOf = async (trust) => trust.kind === "member" ? { handle: trust.member.handle, email: trust.member.email } : localPerson();
81
+ /**
82
+ * A private routine's run (meta.private) is not there for a remote caller
83
+ * (trust.ts remoteCaller): a 404, as if it did not exist. This machine and
84
+ * the LAN see it.
85
+ */
86
+ async function hiddenFrom(trust, id) {
87
+ if (!remoteCaller(trust))
88
+ return;
89
+ const meta = await readMeta(id).catch(() => null);
90
+ if (meta?.private)
91
+ fail(404, "not found");
92
+ }
93
+ /** Every route on one chat (`:id`) checks hiddenFrom first. */
94
+ function guarded(r) {
95
+ if (!r.path.includes("/:id"))
96
+ return r;
97
+ const handle = r.handle;
98
+ return {
99
+ ...r,
100
+ handle: async (c) => {
101
+ await hiddenFrom(c.trust, c.params.id);
102
+ return handle(c);
103
+ },
104
+ };
105
+ }
78
106
  /** Chats: ACP sessions with a coding agent, their event stream, and what a person does in them. */
79
107
  export const chatRoutes = [
80
- route({ name: "chats.list", method: "GET", path: "/api/chats", summary: "every chat with its live state" }, async () => ({ chats: await listChats() })),
108
+ route({ name: "chats.list", method: "GET", path: "/api/chats", summary: "every chat with its live state; a remote caller's list leaves private routines' runs out" }, async (c) => {
109
+ const chats = await listChats();
110
+ return { chats: remoteCaller(c.trust) ? chats.filter((x) => !x.private) : chats };
111
+ }),
81
112
  route({ name: "chats.create", method: "POST", path: "/api/chats", summary: "start a chat {agent, scope, resume?, project?}", body: NewChat }, async (c) => {
82
113
  // `resume` continues one of the agent's own sessions (see agents.sessions).
83
114
  const body = await c.body();
@@ -88,7 +119,13 @@ export const chatRoutes = [
88
119
  fail(400, "only an agent's chat takes a project");
89
120
  const project = body.project ? await projectSlug(body.project) : undefined;
90
121
  try {
91
- return await createChat({ agent: agent, scope, ...(project ? { project } : {}), ...(body.resume ? { resume: body.resume } : {}) });
122
+ return await createChat({
123
+ agent: agent,
124
+ scope,
125
+ author: await personOf(c.trust),
126
+ ...(project ? { project } : {}),
127
+ ...(body.resume ? { resume: body.resume } : {}),
128
+ });
92
129
  }
93
130
  catch (err) {
94
131
  throw new ApiError(400, err.message);
@@ -146,7 +183,7 @@ export const chatRoutes = [
146
183
  const { slug } = await c.body();
147
184
  return metaOf(await setChatVibeable(c.params.id, slug || null));
148
185
  }),
149
- route({ name: "chats.fork", method: "POST", path: "/api/chats/:id/fork", summary: "a new chat continuing a copy of this one's session", errors: 400 }, async (c) => metaOf(await forkChat(c.params.id))),
186
+ route({ name: "chats.fork", method: "POST", path: "/api/chats/:id/fork", summary: "a new chat continuing a copy of this one's session", errors: 400 }, async (c) => metaOf(await forkChat(c.params.id, await personOf(c.trust)))),
150
187
  route({ name: "chats.resetSession", method: "POST", path: "/api/chats/:id/reset-session", summary: "forget the agent's session; the next message starts fresh", errors: 400 }, async (c) => metaOf(await resetSession(c.params.id))),
151
188
  // The body is the file itself; x-file-name names it, content-type types it.
152
189
  route({ name: "chats.attach", method: "POST", path: "/api/chats/:id/attachments", summary: "upload an attachment for the next message", errors: 400, upload: true }, async (c) => {
@@ -178,8 +215,8 @@ export const chatRoutes = [
178
215
  res.end(data);
179
216
  }),
180
217
  // Channels: `from` is the poster's display name, which signs the message.
181
- act("chats.message", "/api/chats/:id/message", "send a message {text, attachments?, from?}", Said.extend({ from: z.string().optional() }), (c, body) => postMessage(c.params.id, textOf(body), { from: body.from, attachments: body.attachments })),
182
- act("chats.steer", "/api/chats/:id/steer", "add to the running turn {text, attachments?}", Said, (c, body) => steerChat(c.params.id, textOf(body), body.attachments)),
218
+ act("chats.message", "/api/chats/:id/message", "send a message {text, attachments?, from?}", Said.extend({ from: z.string().optional() }), async (c, body) => postMessage(c.params.id, textOf(body), { from: body.from, attachments: body.attachments, by: await personOf(c.trust) })),
219
+ act("chats.steer", "/api/chats/:id/steer", "add to the running turn {text, attachments?}", Said, async (c, body) => steerChat(c.params.id, textOf(body), body.attachments, await personOf(c.trust))),
183
220
  act("chats.permission", "/api/chats/:id/permission", "answer a permission request {requestId, optionId | null}", z.object({ requestId: z.string().default(""), optionId: z.string().nullable().default(null) }), (c, body) => resolvePermission(c.params.id, body.requestId, body.optionId)),
184
221
  // Channels: `agent` stops one member; without it every agent in the chat.
185
222
  act("chats.cancel", "/api/chats/:id/cancel", "stop the running turn {agent?}", z.object({ agent: z.string().optional() }), (c, body) => cancelChat(c.params.id, body.agent || undefined)),
@@ -190,4 +227,4 @@ export const chatRoutes = [
190
227
  }), (c, { requestId, action, content }) => answerElicitation(c.params.id, requestId, action === "accept" ? { action, content } : { action })),
191
228
  act("chats.stopTask", "/api/chats/:id/task-stop", "stop one background task {taskId}", z.object({ taskId: z.string().default("") }), (c, body) => stopChatTask(c.params.id, body.taskId)),
192
229
  act("chats.config", "/api/chats/:id/config", "set a session option {configId, value}", z.object({ configId: z.string().default(""), value: z.union([z.string(), z.boolean()]).default("") }), (c, body) => setChatConfig(c.params.id, body.configId, body.value)),
193
- ];
230
+ ].map(guarded);
@@ -4,9 +4,23 @@ import * as z from "zod";
4
4
  * it on or off, a round now, releasing the claim. What `kraftwerk cloud`,
5
5
  * `login` and `logout` ask the running daemon; a standalone server answers
6
6
  * "off" (only the daemon registers). This machine only: the claim code
7
- * claims the machine.
7
+ * claims the machine. And an invite to one served workspace (`kraftwerk
8
+ * invite`): someone joins it as a remote member, without a clone.
8
9
  */
9
- export declare const cloudRoutes: (import("./router.js").Route<"cloud.set", {
10
+ export declare const cloudRoutes: (import("./router.js").Route<"cloud.invite", import("../../core/cloud.js").InviteResponse & {
11
+ workspace: {
12
+ id: string;
13
+ slug: string;
14
+ name: string;
15
+ };
16
+ }, "/api/cloud/invite", z.ZodObject<{
17
+ slug: z.ZodOptional<z.ZodString>;
18
+ root: z.ZodOptional<z.ZodString>;
19
+ role: z.ZodOptional<z.ZodEnum<{
20
+ member: "member";
21
+ owner: "owner";
22
+ }>>;
23
+ }, z.core.$strip>> | import("./router.js").Route<"cloud.set", {
10
24
  daemon: boolean;
11
25
  url: string;
12
26
  state: import("../cloud.js").CloudState;
@@ -1,6 +1,6 @@
1
1
  import * as z from "zod";
2
2
  import { writeCloudFile } from "../../core/cloud.js";
3
- import { applyMachineCloud, cloudStatus, syncMachineCloud, unlinkMachineCloud } from "../cloud.js";
3
+ import { applyMachineCloud, cloudStatus, createWorkspaceInvite, syncMachineCloud, unlinkMachineCloud } from "../cloud.js";
4
4
  import { currentHub } from "../hub-context.js";
5
5
  import { route } from "./router.js";
6
6
  /**
@@ -8,7 +8,8 @@ import { route } from "./router.js";
8
8
  * it on or off, a round now, releasing the claim. What `kraftwerk cloud`,
9
9
  * `login` and `logout` ask the running daemon; a standalone server answers
10
10
  * "off" (only the daemon registers). This machine only: the claim code
11
- * claims the machine.
11
+ * claims the machine. And an invite to one served workspace (`kraftwerk
12
+ * invite`): someone joins it as a remote member, without a clone.
12
13
  */
13
14
  export const cloudRoutes = [
14
15
  route({ name: "cloud.status", method: "GET", path: "/api/cloud", summary: "the machine's cloud record: state, claim code, account, last heartbeat", access: "local" }, async () => ({
@@ -31,6 +32,18 @@ export const cloudRoutes = [
31
32
  daemon: currentHub().daemon === true,
32
33
  ...(await syncMachineCloud()),
33
34
  })),
35
+ route({
36
+ name: "cloud.invite",
37
+ method: "POST",
38
+ path: "/api/cloud/invite",
39
+ summary: "an invite to a served workspace {slug | root, role?}: the cloud's join code and link (a claimed machine only)",
40
+ access: "local",
41
+ errors: 409,
42
+ body: z.object({ slug: z.string().optional(), root: z.string().optional(), role: z.enum(["owner", "member"]).optional() }).refine((b) => b.slug || b.root, "name the workspace: slug or root"),
43
+ }, async (c) => {
44
+ const { slug, root, role } = await c.body();
45
+ return createWorkspaceInvite({ slug, root }, role);
46
+ }),
34
47
  route({ name: "cloud.unlink", method: "POST", path: "/api/cloud/unlink", summary: "release the machine from its account; the cloud hands out a fresh claim code", access: "local", errors: 400 }, async () => ({
35
48
  daemon: currentHub().daemon === true,
36
49
  ...(await unlinkMachineCloud()),
@@ -29,8 +29,14 @@ export declare const deviceRoutes: (import("./router.js").Route<"devices.code",
29
29
  }, "/api/devices/relay", undefined> | import("./router.js").Route<"devices.revoke", {
30
30
  ok: boolean;
31
31
  }, "/api/devices/:id", undefined> | import("./router.js").Route<"devices.self", {
32
- trust: "device" | "local" | "none";
32
+ trust: "device" | "local" | "member" | "none";
33
33
  device?: import("../../core/devices.js").Device | undefined;
34
+ relay?: boolean | undefined;
35
+ member?: import("../../core/cloud.js").MemberVerdict | undefined;
36
+ workspace?: {
37
+ id: string;
38
+ slug: string;
39
+ } | undefined;
34
40
  }, "/api/devices/self", undefined> | import("./router.js").Route<"devices.setPush", {
35
41
  ok: boolean;
36
42
  }, "/api/devices/push", z.ZodObject<{
@@ -44,9 +44,10 @@ export const deviceRoutes = [
44
44
  throw new ApiError(400, err.message);
45
45
  }
46
46
  }),
47
- route({ name: "devices.self", method: "GET", path: "/api/devices/self", summary: "how this caller is trusted: this machine or a paired device" }, async (c) => ({
47
+ route({ name: "devices.self", method: "GET", path: "/api/devices/self", summary: "how this caller is trusted: this machine, a paired device, or a workspace's member" }, async (c) => ({
48
48
  trust: c.trust.kind,
49
- ...(c.trust.kind === "device" ? { device: c.trust.device } : {}),
49
+ ...(c.trust.kind === "device" ? { device: c.trust.device, ...(c.trust.relay ? { relay: true } : {}) } : {}),
50
+ ...(c.trust.kind === "member" ? { member: c.trust.member, workspace: { id: c.trust.workspaceId, slug: c.trust.slug } } : {}),
50
51
  })),
51
52
  // A paired device's app says where its push notifications go (and the key it seals them with); see server/push.ts.
52
53
  route({
@@ -291,6 +291,19 @@ export declare const routes: (Route<"agents.addJournal", {
291
291
  ok: true;
292
292
  }, "/api/chats/:id/task-stop", z.ZodObject<{
293
293
  taskId: z.ZodDefault<z.ZodString>;
294
+ }, z.core.$strip>> | Route<"cloud.invite", import("../../core/cloud.js").InviteResponse & {
295
+ workspace: {
296
+ id: string;
297
+ slug: string;
298
+ name: string;
299
+ };
300
+ }, "/api/cloud/invite", z.ZodObject<{
301
+ slug: z.ZodOptional<z.ZodString>;
302
+ root: z.ZodOptional<z.ZodString>;
303
+ role: z.ZodOptional<z.ZodEnum<{
304
+ member: "member";
305
+ owner: "owner";
306
+ }>>;
294
307
  }, z.core.$strip>> | Route<"cloud.set", {
295
308
  daemon: boolean;
296
309
  url: string;
@@ -383,8 +396,14 @@ export declare const routes: (Route<"agents.addJournal", {
383
396
  }, "/api/devices/relay", undefined> | Route<"devices.revoke", {
384
397
  ok: boolean;
385
398
  }, "/api/devices/:id", undefined> | Route<"devices.self", {
386
- trust: "device" | "local" | "none";
399
+ trust: "device" | "local" | "member" | "none";
387
400
  device?: import("../../core/devices.js").Device | undefined;
401
+ relay?: boolean | undefined;
402
+ member?: import("../../core/cloud.js").MemberVerdict | undefined;
403
+ workspace?: {
404
+ id: string;
405
+ slug: string;
406
+ } | undefined;
388
407
  }, "/api/devices/self", undefined> | Route<"devices.setPush", {
389
408
  ok: boolean;
390
409
  }, "/api/devices/push", z.ZodObject<{
@@ -59,8 +59,9 @@ export interface RouteDef<N extends string, P extends string = string, S extends
59
59
  body?: S;
60
60
  /**
61
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).
62
+ * device, a workspace's member — except the machine-level routes
63
+ * MEMBER_DENIED names). `public`: also unpaired callers (pairing, the
64
+ * protocol). `local`: this machine only (managing devices).
64
65
  */
65
66
  access?: "public" | "local";
66
67
  }
@@ -39,12 +39,24 @@ function validate(route, raw) {
39
39
  }
40
40
  /** Maps an error message to a status by the usual wording: "no <thing>" is 404, "… are off" 409, else 400. */
41
41
  export const statusByMessage = (missing) => (message) => missing.test(message) ? 404 : /are off/.test(message) ? 409 : 400;
42
+ /**
43
+ * What a member session (one workspace, over the relay; see members.ts) may
44
+ * not call: the machine's own business — its workspaces, devices, cloud
45
+ * record, settings, updates, restarts, identity, trash. server.ts already
46
+ * keeps the session inside its workspace; this keeps it out of the machine.
47
+ */
48
+ const MEMBER_DENIED = /^(hub|devices|cloud|settings|server|update|workspaces|identity|trash)\./;
49
+ /** …except asking who it is. */
50
+ const MEMBER_ALLOWED = new Set(["devices.self"]);
42
51
  /** Why a caller may not use a route, or undefined when it may. */
43
52
  function refusal(route, trust) {
44
53
  if (route.access === "public")
45
54
  return undefined;
46
55
  if (trust.kind === "none")
47
56
  return new ApiError(401, "pair this device first", { error: "pair this device first", pair: true });
57
+ if (trust.kind === "member" && (route.access === "local" || (MEMBER_DENIED.test(route.name) && !MEMBER_ALLOWED.has(route.name)))) {
58
+ return new ApiError(403, `${route.name} is not open to a workspace member — only to the machine kraftwerk runs on`);
59
+ }
48
60
  if (route.access === "local" && trust.kind !== "local")
49
61
  return new ApiError(403, "only on the machine kraftwerk runs on");
50
62
  return undefined;
@@ -1,4 +1,4 @@
1
- import { type CloudUrlSource } from "../core/cloud.js";
1
+ import { type Credentials, type CloudUrlSource, type InviteResponse } from "../core/cloud.js";
2
2
  /**
3
3
  * The machine client: the daemon registers THIS MACHINE with the kraftwerk
4
4
  * cloud once and heartbeats while it runs; the workspaces it serves ride
@@ -16,7 +16,12 @@ import { type CloudUrlSource } from "../core/cloud.js";
16
16
  * along whenever it changed (opened, closed, vetoed, claimed).
17
17
  * A claim made in the cloud arrives in the answer
18
18
  * privacy while unclaimed, entries carry name/slug/ws-id only — no
19
- * roots; once claimed, roots too
19
+ * roots; once claimed, roots too, and the routines that ran
20
+ * here (`routines`, sent when it changed; private ones never)
21
+ * members the machine's credentials and the served workspaces are
22
+ * what member sessions over the relay are verified with
23
+ * (server/members.ts), and what `kraftwerk invite` asks the
24
+ * cloud with (createWorkspaceInvite)
20
25
  * recovery a cloud that no longer knows the machine (data reset) gets a
21
26
  * fresh registration; a cloud that is down is retried at the
22
27
  * same cadence and never affects the UI
@@ -50,11 +55,14 @@ export interface CloudStatus {
50
55
  /** Workspaces reported (vetoed ones left out). */
51
56
  workspaces?: number;
52
57
  }
53
- /** What the daemon serves right now. */
58
+ /** What the daemon serves right now; `ws` runs code inside that workspace (the routine report reads its state). */
54
59
  export type WorkspaceSource = () => Array<{
55
60
  root: string;
56
61
  slug: string;
57
62
  name: string;
63
+ ws?: {
64
+ run<T>(fn: () => T): T;
65
+ };
58
66
  }>;
59
67
  export declare const cloudStatus: () => CloudStatus;
60
68
  /**
@@ -83,3 +91,34 @@ export declare function unlinkMachineCloud(): Promise<CloudStatus>;
83
91
  * started (a hub stopping in a test leaves another's alone).
84
92
  */
85
93
  export declare function stopMachineCloud(owner?: unknown): Promise<void>;
94
+ /**
95
+ * What member verification needs (server/members.ts): the cloud's origin,
96
+ * this machine's credentials. Null when no machine client runs (a
97
+ * standalone server, the cloud turned off).
98
+ */
99
+ export declare function machineLink(): {
100
+ url: string;
101
+ creds: Credentials;
102
+ version: string;
103
+ } | null;
104
+ /** The served workspace whose kraftwerk.yml `id` is `id` (vetoed ones not), or null. */
105
+ export declare function servedWorkspace(id: string): Promise<{
106
+ slug: string;
107
+ name: string;
108
+ root: string;
109
+ } | null>;
110
+ /**
111
+ * An invite to a served workspace (`kraftwerk invite`): the cloud's machine
112
+ * path, so only a claimed machine that reports the workspace gets one.
113
+ * `which` is the workspace's slug or root.
114
+ */
115
+ export declare function createWorkspaceInvite(which: {
116
+ slug?: string;
117
+ root?: string;
118
+ }, role?: "owner" | "member"): Promise<InviteResponse & {
119
+ workspace: {
120
+ id: string;
121
+ slug: string;
122
+ name: string;
123
+ };
124
+ }>;
@@ -1,8 +1,9 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import os from "node:os";
3
3
  import { resolveWorkspace } from "../config.js";
4
- import { claimUrlFor, machineCloud, machineGoodbye, machineHeartbeat, machineRegister, machineUnlink, retiredCloudDir, unknownMachine, } from "../core/cloud.js";
4
+ import { claimUrlFor, machineCloud, machineGoodbye, machineHeartbeat, machineRegister, machineUnlink, MAX_REPORTED_ROUTINES, retiredCloudDir, unknownMachine, workspaceInvite, } from "../core/cloud.js";
5
5
  import { identityString, readIdentity } from "../core/identity.js";
6
+ import { routineReport } from "../core/routines.js";
6
7
  import { machineCredentials } from "./relay.js";
7
8
  const DEFAULT_INTERVAL = 60;
8
9
  const STANDALONE = { url: "", state: "off", reason: "only the daemon registers this machine with the cloud (`kraftwerk daemon start`)" };
@@ -17,6 +18,8 @@ const m = {
17
18
  version: "",
18
19
  /** The workspace list the cloud last acknowledged, as JSON: sent again only when it differs. */
19
20
  reported: "",
21
+ /** The routine report the cloud last took, as JSON (claimed only; "" sends it again). */
22
+ routines: "",
20
23
  /** One round at a time: a timer tick, a sync from the CLI and a workspace change never overlap. */
21
24
  lock: Promise.resolve(),
22
25
  warned: new Set(),
@@ -43,6 +46,26 @@ async function entries(withRoots) {
43
46
  return { list, ymlUrls };
44
47
  }
45
48
  const claimed = () => typeof m.status.account === "string" && m.status.account.length > 0;
49
+ /** The routines that ran here, per served workspace with a ws_ id (vetoed ones left out), capped. */
50
+ async function routineEntries() {
51
+ const out = [];
52
+ for (const o of m.source()) {
53
+ const ws = await resolveWorkspace(o.root).catch(() => null);
54
+ const id = ws?.config.id;
55
+ if (!id || ws.config.cloud?.enabled === false || !o.ws)
56
+ continue;
57
+ // A workspace closing meanwhile throws "workspace closed": it simply reports nothing.
58
+ const run = o.ws.run.bind(o.ws);
59
+ const ran = await Promise.resolve()
60
+ .then(() => run(() => routineReport()))
61
+ .catch(() => []);
62
+ for (const r of ran)
63
+ out.push({ workspaceId: id, ...r });
64
+ if (out.length >= MAX_REPORTED_ROUTINES)
65
+ break;
66
+ }
67
+ return out.slice(0, MAX_REPORTED_ROUTINES);
68
+ }
46
69
  function warnYml(cloud, ymlUrls) {
47
70
  if (!ymlUrls.length)
48
71
  return;
@@ -85,6 +108,7 @@ async function register() {
85
108
  version: m.version,
86
109
  }, list);
87
110
  m.registered = true;
111
+ m.routines = "";
88
112
  // An unclaimed register carries no roots: the list counts as reported only when that is all the cloud should know.
89
113
  m.reported = r.account ? "" : JSON.stringify(list);
90
114
  m.status = {
@@ -108,9 +132,17 @@ async function heartbeat() {
108
132
  const wasClaimed = claimed();
109
133
  const { list } = await entries(wasClaimed);
110
134
  const now = JSON.stringify(list);
135
+ // Routines only while claimed; unclaimed, the next claim sends them afresh.
136
+ const routines = wasClaimed ? await routineEntries() : null;
137
+ const ran = routines ? JSON.stringify(routines) : "";
111
138
  try {
112
- const r = await machineHeartbeat(m.status.url, m.creds, { version: m.version, ...(now !== m.reported ? { workspaces: list } : {}) });
139
+ const r = await machineHeartbeat(m.status.url, m.creds, {
140
+ version: m.version,
141
+ ...(now !== m.reported ? { workspaces: list } : {}),
142
+ ...(routines && ran !== m.routines ? { routines } : {}),
143
+ });
113
144
  m.reported = now;
145
+ m.routines = ran;
114
146
  m.status = {
115
147
  ...m.status, state: "connected", error: undefined, interval: r.interval || m.status.interval || DEFAULT_INTERVAL, lastSeen: new Date().toISOString(),
116
148
  ...(r.account !== undefined ? { account: r.account } : {}),
@@ -225,6 +257,7 @@ export async function unlinkMachineCloud() {
225
257
  const r = await machineUnlink(m.status.url, m.creds, m.version);
226
258
  // The next heartbeat sends the list again — without roots now.
227
259
  m.reported = "";
260
+ m.routines = "";
228
261
  m.status = { ...m.status, account: null, claimCode: r.claimCode ?? null, claimUrl: claimUrlFor(m.status.url, r.claimCode) };
229
262
  return m.status;
230
263
  });
@@ -249,3 +282,45 @@ export async function stopMachineCloud(owner) {
249
282
  if (registered && creds && url)
250
283
  await machineGoodbye(url, creds, m.version).catch(() => { });
251
284
  }
285
+ /**
286
+ * What member verification needs (server/members.ts): the cloud's origin,
287
+ * this machine's credentials. Null when no machine client runs (a
288
+ * standalone server, the cloud turned off).
289
+ */
290
+ export function machineLink() {
291
+ if (!m.started || m.status.state === "off" || !m.creds || !m.status.url)
292
+ return null;
293
+ return { url: m.status.url, creds: m.creds, version: m.version };
294
+ }
295
+ /** The served workspace whose kraftwerk.yml `id` is `id` (vetoed ones not), or null. */
296
+ export async function servedWorkspace(id) {
297
+ for (const o of m.source()) {
298
+ const ws = await resolveWorkspace(o.root).catch(() => null);
299
+ if (ws?.config.id === id && ws.config.cloud?.enabled !== false)
300
+ return { slug: o.slug, name: o.name, root: o.root };
301
+ }
302
+ return null;
303
+ }
304
+ /**
305
+ * An invite to a served workspace (`kraftwerk invite`): the cloud's machine
306
+ * path, so only a claimed machine that reports the workspace gets one.
307
+ * `which` is the workspace's slug or root.
308
+ */
309
+ export async function createWorkspaceInvite(which, role) {
310
+ const o = m.source().find((x) => (which.root && x.root === which.root) || (which.slug && x.slug === which.slug));
311
+ if (!o)
312
+ throw new Error(`the daemon does not serve ${which.slug ? `"${which.slug}"` : which.root} — open it first (\`kraftwerk ui\` in its folder)`);
313
+ const ws = await resolveWorkspace(o.root);
314
+ if (ws.config.cloud?.enabled === false)
315
+ throw new Error(`${o.slug} is left out of the cloud (cloud.enabled: false in kraftwerk.yml)`);
316
+ const id = ws.config.id;
317
+ if (!id)
318
+ throw new Error(`${o.slug} has no workspace id (ws_…) in kraftwerk.yml — run \`kraftwerk doctor --fix\``);
319
+ const link = machineLink();
320
+ if (!link)
321
+ throw new Error(m.status.reason ? `the cloud is off: ${m.status.reason}` : "this machine is not connected to the kraftwerk cloud — `kraftwerk daemon start`");
322
+ if (!claimed())
323
+ throw new Error("this machine is not claimed — run `kraftwerk login` first");
324
+ const invite = await workspaceInvite(link.url, link.creds, id, role, link.version);
325
+ return { ...invite, workspace: { id, slug: o.slug, name: o.name } };
326
+ }
@@ -0,0 +1,87 @@
1
+ import { type MemberVerdict } from "../core/cloud.js";
2
+ /**
3
+ * Remote members: people who joined a workspace by invite in the cloud and
4
+ * have no clone of it. They hold a workspace-scoped member token (`kwm_…`)
5
+ * and reach the workspace through the relay: their app opens the encrypted
6
+ * channel (server/relay.ts) and, as its first sealed message, presents
7
+ *
8
+ * → {t:"auth", memberToken: "kwm_…", workspaceId: "ws_…"}
9
+ * ← {t:"auth", ok: true, member: {email, handle, role}, workspace: {id, slug, name}}
10
+ * ← {t:"auth", ok: false, error} and the channel closes
11
+ *
12
+ * instead of a paired device's token. This daemon asks the cloud
13
+ * (/api/members/verify, with the machine's credentials) and caches the
14
+ * answer per (token, workspace) for five minutes — short, because revoking
15
+ * a member kills their tokens in the cloud at once, and the cache is all
16
+ * that keeps them in until it expires:
17
+ *
18
+ * fresh within the TTL the cache answers, the cloud is not asked
19
+ * expired a live session re-verifies lazily with its next request
20
+ * unreachable the cloud down, a 5xx, no machine client: a cached entry is
21
+ * KEPT and used (stale); without one the session is refused
22
+ * revoked 404 (unknown or revoked token) or 403 (this machine does not
23
+ * host the workspace) drops the entry, and the session with it
24
+ *
25
+ * A session is scoped to that one workspace: the relay tags each of its
26
+ * requests with the session's random id (MEMBER_HEADER), which only this
27
+ * process knows, and trust.ts turns the tag into `member` trust; server.ts
28
+ * and router.ts refuse everything outside the workspace. Paired devices are
29
+ * untouched — machine-wide, as before.
30
+ */
31
+ export type Member = MemberVerdict;
32
+ /** The header the relay puts on a member session's requests (a client's own is dropped). */
33
+ export declare const MEMBER_HEADER = "x-kraftwerk-member";
34
+ /** How long a verified answer counts as fresh — tests shorten it; no argument restores the default. */
35
+ export declare function setMemberCacheTtl(ms?: number): void;
36
+ /** Forget every cached answer (tests). */
37
+ export declare function forgetMemberCache(): void;
38
+ export type Verdict = {
39
+ ok: true;
40
+ member: Member;
41
+ stale?: true;
42
+ }
43
+ /** `drop`: the cloud said no (the session ends); otherwise it could not be asked. */
44
+ | {
45
+ ok: false;
46
+ error: string;
47
+ drop: boolean;
48
+ };
49
+ /** Whether `token` is a member of `workspaceId`: the cache within its TTL, else the cloud. */
50
+ export declare function verifyMember(token: string, workspaceId: string): Promise<Verdict>;
51
+ /**
52
+ * A member's hello: verify the token for the workspace, find the workspace
53
+ * this daemon serves under that id, and open a session. Its id is what the
54
+ * relay tags the session's requests with.
55
+ */
56
+ export declare function openMemberSession(token: unknown, workspaceId: unknown): Promise<{
57
+ ok: true;
58
+ id: string;
59
+ member: Member;
60
+ workspace: {
61
+ id: string;
62
+ slug: string;
63
+ name: string;
64
+ };
65
+ } | {
66
+ ok: false;
67
+ error: string;
68
+ }>;
69
+ /** The session behind a request's tag, or undefined. */
70
+ export declare function memberSession(id: string): {
71
+ member: Member;
72
+ workspaceId: string;
73
+ slug: string;
74
+ } | undefined;
75
+ /**
76
+ * Before each of a session's requests: still a member? Within the TTL this
77
+ * is the cache; past it, the cloud (a cloud that cannot be reached keeps the
78
+ * cached answer). A refusal ends the session.
79
+ */
80
+ export declare function checkMemberSession(id: string): Promise<{
81
+ ok: true;
82
+ } | {
83
+ ok: false;
84
+ error: string;
85
+ ended: boolean;
86
+ }>;
87
+ export declare function closeMemberSession(id: string): void;