@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.
- package/dist/cli/invite.d.ts +2 -0
- package/dist/cli/invite.js +48 -0
- package/dist/cli/kraftwerk.js +3 -0
- package/dist/client/relay.d.ts +40 -0
- package/dist/client/relay.js +35 -0
- package/dist/client/routes.d.ts +1 -0
- package/dist/client/routes.js +1 -0
- package/dist/core/chat/channel-turns.d.ts +2 -2
- package/dist/core/chat/channel-turns.js +2 -2
- package/dist/core/chat/sessions.d.ts +20 -3
- package/dist/core/chat/sessions.js +22 -7
- package/dist/core/chat/types.d.ts +21 -0
- package/dist/core/cloud.d.ts +38 -1
- package/dist/core/cloud.js +4 -0
- package/dist/core/routines.d.ts +22 -2
- package/dist/core/routines.js +26 -0
- package/dist/server/api/chats.js +45 -8
- package/dist/server/api/cloud.d.ts +16 -2
- package/dist/server/api/cloud.js +15 -2
- package/dist/server/api/devices.d.ts +7 -1
- package/dist/server/api/devices.js +3 -2
- package/dist/server/api/index.d.ts +20 -1
- package/dist/server/api/router.d.ts +3 -2
- package/dist/server/api/router.js +12 -0
- package/dist/server/cloud.d.ts +42 -3
- package/dist/server/cloud.js +77 -2
- package/dist/server/members.d.ts +87 -0
- package/dist/server/members.js +117 -0
- package/dist/server/relay.d.ts +8 -1
- package/dist/server/relay.js +45 -2
- package/dist/server/server.js +7 -0
- package/dist/server/trust.d.ts +16 -0
- package/dist/server/trust.js +12 -1
- package/package.json +1 -1
- package/web/dist/assets/{dist-BGHweZKi.js → dist-B-yVL5lk.js} +1 -1
- package/web/dist/assets/{dist-BVPxOqcx.js → dist-B9EMB1Ie.js} +1 -1
- package/web/dist/assets/{dist-DT2Tjvwx.js → dist-BTc878j7.js} +1 -1
- package/web/dist/assets/{dist-DoD3qX1P.js → dist-C90kUzps.js} +1 -1
- package/web/dist/assets/{dist-DCFREMXk.js → dist-CpLdAqFQ.js} +1 -1
- package/web/dist/assets/{dist-CXoAMU1b.js → dist-Crnb8VVO.js} +1 -1
- package/web/dist/assets/{dist-FFA4-S6d.js → dist-D0rjnN8S.js} +1 -1
- package/web/dist/assets/{dist-yGx0qYbw.js → dist-D4SDXoGw.js} +1 -1
- package/web/dist/assets/{dist-DdA5PoVJ.js → dist-DHRhdODt.js} +1 -1
- package/web/dist/assets/{dist-CCNyRSAA.js → dist-DQbifCX0.js} +1 -1
- package/web/dist/assets/dist-lSJ-O7kT.js +1 -0
- package/web/dist/assets/{editor-CyWapLQr.js → editor-DHAdW1g_.js} +3 -3
- package/web/dist/assets/{index-CNXcNoKJ.js → index-D7JVAjll.js} +4 -4
- package/web/dist/index.html +1 -1
- package/web/dist-remote/sw.js +1 -1
- package/web/dist/assets/dist-zjUUP5a9.js +0 -1
package/dist/server/api/chats.js
CHANGED
|
@@ -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 () =>
|
|
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({
|
|
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.
|
|
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;
|
package/dist/server/api/cloud.js
CHANGED
|
@@ -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
|
|
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
|
|
63
|
-
* `
|
|
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;
|
package/dist/server/cloud.d.ts
CHANGED
|
@@ -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
|
+
}>;
|
package/dist/server/cloud.js
CHANGED
|
@@ -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, {
|
|
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;
|