@netnodeag/kraftwerk 0.76.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/README.md +33 -27
- package/dist/cli/cloud.d.ts +2 -0
- package/dist/cli/cloud.js +262 -0
- package/dist/cli/daemon.js +9 -0
- package/dist/cli/doctor.js +16 -8
- package/dist/cli/init.js +2 -2
- package/dist/cli/invite.d.ts +2 -0
- package/dist/cli/invite.js +48 -0
- package/dist/cli/kraftwerk.js +7 -0
- package/dist/client/relay.d.ts +40 -0
- package/dist/client/relay.js +35 -0
- package/dist/client/routes.d.ts +5 -0
- package/dist/client/routes.js +5 -0
- package/dist/config.d.ts +15 -28
- package/dist/config.js +5 -19
- 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 +152 -0
- package/dist/core/cloud.js +98 -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 +86 -0
- package/dist/server/api/cloud.js +51 -0
- package/dist/server/api/devices.d.ts +15 -9
- package/dist/server/api/devices.js +3 -2
- package/dist/server/api/index.d.ts +108 -11
- package/dist/server/api/index.js +2 -0
- package/dist/server/api/router.d.ts +3 -2
- package/dist/server/api/router.js +12 -0
- package/dist/server/api/workspace.d.ts +16 -1
- package/dist/server/api/workspace.js +2 -1
- package/dist/server/cloud.d.ts +104 -33
- package/dist/server/cloud.js +279 -185
- package/dist/server/hub-context.d.ts +1 -1
- package/dist/server/hub-context.js +3 -3
- package/dist/server/members.d.ts +87 -0
- package/dist/server/members.js +117 -0
- package/dist/server/relay.d.ts +21 -1
- package/dist/server/relay.js +74 -3
- package/dist/server/server.d.ts +3 -2
- package/dist/server/server.js +13 -6
- package/dist/server/trust.d.ts +16 -0
- package/dist/server/trust.js +12 -1
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +4 -4
- package/web/dist/assets/{dist-T1sNLC62.js → dist-B-yVL5lk.js} +1 -1
- package/web/dist/assets/{dist-DFXmaDc5.js → dist-B9EMB1Ie.js} +1 -1
- package/web/dist/assets/{dist-BQpWYkxm.js → dist-BTc878j7.js} +1 -1
- package/web/dist/assets/{dist-fgB7OEJy.js → dist-C90kUzps.js} +1 -1
- package/web/dist/assets/{dist-l7foWlFx.js → dist-CpLdAqFQ.js} +1 -1
- package/web/dist/assets/{dist-flnLXEzU.js → dist-Crnb8VVO.js} +1 -1
- package/web/dist/assets/{dist-vuciRCSz.js → dist-D0rjnN8S.js} +1 -1
- package/web/dist/assets/{dist-BTVP-6On.js → dist-D4SDXoGw.js} +1 -1
- package/web/dist/assets/{dist-BwIca_Uj.js → dist-DHRhdODt.js} +1 -1
- package/web/dist/assets/{dist-BYkcY-Vl.js → dist-DQbifCX0.js} +1 -1
- package/web/dist/assets/dist-lSJ-O7kT.js +1 -0
- package/web/dist/assets/{editor-C6Ok8NPo.js → editor-DHAdW1g_.js} +3 -3
- package/web/dist/assets/{index-z4tpCCzk.js → index-D7JVAjll.js} +5 -5
- package/web/dist/index.html +1 -1
- package/web/dist-remote/sw.js +1 -1
- package/web/dist/assets/dist-BURf1l-A.js +0 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { CloudError, memberVerify } from "../core/cloud.js";
|
|
3
|
+
import { machineLink, servedWorkspace } from "./cloud.js";
|
|
4
|
+
/** The header the relay puts on a member session's requests (a client's own is dropped). */
|
|
5
|
+
export const MEMBER_HEADER = "x-kraftwerk-member";
|
|
6
|
+
const DEFAULT_TTL_MS = 5 * 60_000;
|
|
7
|
+
let ttlMs = DEFAULT_TTL_MS;
|
|
8
|
+
/** How long a verified answer counts as fresh — tests shorten it; no argument restores the default. */
|
|
9
|
+
export function setMemberCacheTtl(ms) {
|
|
10
|
+
ttlMs = ms ?? DEFAULT_TTL_MS;
|
|
11
|
+
}
|
|
12
|
+
/** By sha256(token) + workspace: the cache keeps no token in the clear. */
|
|
13
|
+
const cache = new Map();
|
|
14
|
+
const inflight = new Map();
|
|
15
|
+
/** Forget every cached answer (tests). */
|
|
16
|
+
export function forgetMemberCache() {
|
|
17
|
+
cache.clear();
|
|
18
|
+
}
|
|
19
|
+
const keyOf = (token, workspaceId) => `${createHash("sha256").update(token).digest("hex")}\0${workspaceId}`;
|
|
20
|
+
const ROLES = new Set(["owner", "member"]);
|
|
21
|
+
function asMember(v) {
|
|
22
|
+
const o = (v ?? {});
|
|
23
|
+
if (typeof o.email !== "string" || typeof o.handle !== "string" || !ROLES.has(String(o.role)))
|
|
24
|
+
return null;
|
|
25
|
+
return { email: o.email, handle: o.handle, role: o.role };
|
|
26
|
+
}
|
|
27
|
+
/** Whether `token` is a member of `workspaceId`: the cache within its TTL, else the cloud. */
|
|
28
|
+
export function verifyMember(token, workspaceId) {
|
|
29
|
+
const key = keyOf(token, workspaceId);
|
|
30
|
+
const hit = cache.get(key);
|
|
31
|
+
if (hit && Date.now() - hit.at < ttlMs)
|
|
32
|
+
return Promise.resolve({ ok: true, member: hit.member });
|
|
33
|
+
const running = inflight.get(key);
|
|
34
|
+
if (running)
|
|
35
|
+
return running;
|
|
36
|
+
const run = (async () => {
|
|
37
|
+
const stale = (why) => {
|
|
38
|
+
const kept = cache.get(key);
|
|
39
|
+
return kept ? { ok: true, member: kept.member, stale: true } : { ok: false, error: why, drop: false };
|
|
40
|
+
};
|
|
41
|
+
const link = machineLink();
|
|
42
|
+
if (!link)
|
|
43
|
+
return stale("this machine is not connected to the kraftwerk cloud — members cannot be verified");
|
|
44
|
+
try {
|
|
45
|
+
const member = asMember(await memberVerify(link.url, link.creds, token, workspaceId, link.version));
|
|
46
|
+
if (!member)
|
|
47
|
+
return stale("the cloud's answer about the member was not understood");
|
|
48
|
+
cache.set(key, { member, at: Date.now() });
|
|
49
|
+
return { ok: true, member };
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
const status = err instanceof CloudError ? err.status : 0;
|
|
53
|
+
if (status === 401 || status === 403 || status === 404) {
|
|
54
|
+
cache.delete(key);
|
|
55
|
+
const why = status === 403 ? "this machine does not host that workspace" : "unknown or revoked member token";
|
|
56
|
+
return { ok: false, error: `${why}${err.message ? ` (${err.message})` : ""}`, drop: true };
|
|
57
|
+
}
|
|
58
|
+
return stale(`could not reach the kraftwerk cloud to verify the member: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
inflight.set(key, run);
|
|
62
|
+
void run.finally(() => inflight.delete(key));
|
|
63
|
+
return run;
|
|
64
|
+
}
|
|
65
|
+
const sessions = new Map();
|
|
66
|
+
const WS_ID = /^ws_[A-Za-z0-9_-]{1,64}$/;
|
|
67
|
+
/**
|
|
68
|
+
* A member's hello: verify the token for the workspace, find the workspace
|
|
69
|
+
* this daemon serves under that id, and open a session. Its id is what the
|
|
70
|
+
* relay tags the session's requests with.
|
|
71
|
+
*/
|
|
72
|
+
export async function openMemberSession(token, workspaceId) {
|
|
73
|
+
if (typeof token !== "string" || !token.startsWith("kwm_") || token.length > 512)
|
|
74
|
+
return { ok: false, error: "memberToken must be a kwm_… member token" };
|
|
75
|
+
if (typeof workspaceId !== "string" || !WS_ID.test(workspaceId))
|
|
76
|
+
return { ok: false, error: "workspaceId must be a ws_… workspace id" };
|
|
77
|
+
const v = await verifyMember(token, workspaceId);
|
|
78
|
+
if (!v.ok)
|
|
79
|
+
return { ok: false, error: v.error };
|
|
80
|
+
const ws = await servedWorkspace(workspaceId);
|
|
81
|
+
if (!ws)
|
|
82
|
+
return { ok: false, error: "this machine does not serve that workspace right now" };
|
|
83
|
+
const id = randomBytes(24).toString("base64url");
|
|
84
|
+
sessions.set(id, { token, workspaceId, slug: ws.slug, name: ws.name, member: v.member });
|
|
85
|
+
return { ok: true, id, member: v.member, workspace: { id: workspaceId, slug: ws.slug, name: ws.name } };
|
|
86
|
+
}
|
|
87
|
+
/** The session behind a request's tag, or undefined. */
|
|
88
|
+
export function memberSession(id) {
|
|
89
|
+
const s = sessions.get(id);
|
|
90
|
+
return s ? { member: s.member, workspaceId: s.workspaceId, slug: s.slug } : undefined;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Before each of a session's requests: still a member? Within the TTL this
|
|
94
|
+
* is the cache; past it, the cloud (a cloud that cannot be reached keeps the
|
|
95
|
+
* cached answer). A refusal ends the session.
|
|
96
|
+
*/
|
|
97
|
+
export async function checkMemberSession(id) {
|
|
98
|
+
const s = sessions.get(id);
|
|
99
|
+
if (!s)
|
|
100
|
+
return { ok: false, error: "the member session ended", ended: true };
|
|
101
|
+
const v = await verifyMember(s.token, s.workspaceId);
|
|
102
|
+
if (!v.ok && v.drop) {
|
|
103
|
+
sessions.delete(id);
|
|
104
|
+
return { ok: false, error: v.error, ended: true };
|
|
105
|
+
}
|
|
106
|
+
if (!v.ok)
|
|
107
|
+
return { ok: false, error: v.error, ended: false };
|
|
108
|
+
s.member = v.member;
|
|
109
|
+
// The workspace may have been reopened under another slug meanwhile.
|
|
110
|
+
const ws = await servedWorkspace(s.workspaceId);
|
|
111
|
+
if (ws)
|
|
112
|
+
s.slug = ws.slug;
|
|
113
|
+
return { ok: true };
|
|
114
|
+
}
|
|
115
|
+
export function closeMemberSession(id) {
|
|
116
|
+
sessions.delete(id);
|
|
117
|
+
}
|
package/dist/server/relay.d.ts
CHANGED
|
@@ -5,7 +5,14 @@ import type { PushMessage } from "./push.js";
|
|
|
5
5
|
* it, and every request that comes through answered by this daemon's own
|
|
6
6
|
* server — as a request from a network device, never as this machine. So a
|
|
7
7
|
* client needs a paired device's token (or pairs with a code) exactly as on
|
|
8
|
-
* the LAN.
|
|
8
|
+
* the LAN. Every request is marked `x-kraftwerk-relay: 1`, so a paired
|
|
9
|
+
* device over the relay counts as a remote caller (trust.ts).
|
|
10
|
+
*
|
|
11
|
+
* Or a channel is a remote member's: its first sealed message is
|
|
12
|
+
* {t:"auth", memberToken, workspaceId} (see members.ts for the answer and
|
|
13
|
+
* the cache). From then on every request of the channel carries the
|
|
14
|
+
* session's tag (members.ts MEMBER_HEADER) and is re-checked against the
|
|
15
|
+
* cache first; a membership the cloud revoked closes the channel.
|
|
9
16
|
*
|
|
10
17
|
* Machine settings live in ~/.kraftwerk/relay.json (owner-only): whether
|
|
11
18
|
* the relay is on, which relay, the machine's key pair, and the secret the
|
|
@@ -44,6 +51,19 @@ export interface RelayStatus {
|
|
|
44
51
|
export declare function readRelaySettings(): Promise<RelaySettings | null>;
|
|
45
52
|
/** Turn the relay on or off (creating the machine's key on first use); `url` switches relays. */
|
|
46
53
|
export declare function setRelay(enabled: boolean, url?: string): Promise<RelaySettings>;
|
|
54
|
+
/**
|
|
55
|
+
* The machine's key pair and secret, created (with the relay staying off)
|
|
56
|
+
* when relay.json is missing: the daemon's first start, `kraftwerk login`.
|
|
57
|
+
* The cloud knows the machine by the same id and secret the relay does; a
|
|
58
|
+
* damaged file is refused, never overwritten (it holds the id).
|
|
59
|
+
*/
|
|
60
|
+
export declare function ensureRelayKeys(): Promise<RelaySettings>;
|
|
61
|
+
/** The machine's id (its relay id), public key and secret: what the cloud knows it by. */
|
|
62
|
+
export declare function machineCredentials(): Promise<{
|
|
63
|
+
id: string;
|
|
64
|
+
key: string;
|
|
65
|
+
secret: string;
|
|
66
|
+
}>;
|
|
47
67
|
/** wss://host/api/relay/<role> for a cloud URL. */
|
|
48
68
|
export declare const relayEndpoint: (cloudUrl: string, role: "server" | "client") => string;
|
|
49
69
|
/**
|
package/dist/server/relay.js
CHANGED
|
@@ -3,9 +3,10 @@ import { promises as fs } from "node:fs";
|
|
|
3
3
|
import http from "node:http";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { daemonHandshake, exportKeyPair, generateKeyPair, importKeyPair, relayId, toBase64, fromBase64, } from "../client/relay.js";
|
|
6
|
+
import { daemonHandshake, exportKeyPair, generateKeyPair, importKeyPair, relayId, toBase64, fromBase64, fromBase64Url, } from "../client/relay.js";
|
|
7
7
|
import { CLOUD_DEFAULT_URL } from "../config.js";
|
|
8
8
|
import { dropPushToken } from "../core/devices.js";
|
|
9
|
+
import { checkMemberSession, closeMemberSession, MEMBER_HEADER, openMemberSession } from "./members.js";
|
|
9
10
|
const settingsFile = () => path.join(os.homedir(), ".kraftwerk", "relay.json");
|
|
10
11
|
export async function readRelaySettings() {
|
|
11
12
|
try {
|
|
@@ -36,6 +37,34 @@ export async function setRelay(enabled, url) {
|
|
|
36
37
|
await writeRelaySettings(s);
|
|
37
38
|
return s;
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The machine's key pair and secret, created (with the relay staying off)
|
|
42
|
+
* when relay.json is missing: the daemon's first start, `kraftwerk login`.
|
|
43
|
+
* The cloud knows the machine by the same id and secret the relay does; a
|
|
44
|
+
* damaged file is refused, never overwritten (it holds the id).
|
|
45
|
+
*/
|
|
46
|
+
export async function ensureRelayKeys() {
|
|
47
|
+
const s = await readRelaySettings();
|
|
48
|
+
if (s)
|
|
49
|
+
return s;
|
|
50
|
+
try {
|
|
51
|
+
await fs.access(settingsFile());
|
|
52
|
+
throw new Error(`${settingsFile()} is damaged — fix it or remove it (removing gives this machine a new id)`);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (err.code !== "ENOENT")
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
const pair = await exportKeyPair(await generateKeyPair());
|
|
59
|
+
const fresh = { enabled: false, url: CLOUD_DEFAULT_URL, ...pair, secret: randomBytes(32).toString("base64url") };
|
|
60
|
+
await writeRelaySettings(fresh);
|
|
61
|
+
return fresh;
|
|
62
|
+
}
|
|
63
|
+
/** The machine's id (its relay id), public key and secret: what the cloud knows it by. */
|
|
64
|
+
export async function machineCredentials() {
|
|
65
|
+
const s = await ensureRelayKeys();
|
|
66
|
+
return { id: await relayId(fromBase64Url(s.publicKey)), key: s.publicKey, secret: s.secret };
|
|
67
|
+
}
|
|
39
68
|
/** wss://host/api/relay/<role> for a cloud URL. */
|
|
40
69
|
export const relayEndpoint = (cloudUrl, role) => `${cloudUrl.replace(/\/+$/, "").replace(/^http/, "ws")}/api/relay/${role}`;
|
|
41
70
|
/**
|
|
@@ -86,6 +115,8 @@ export async function startRelay(opts) {
|
|
|
86
115
|
return;
|
|
87
116
|
for (const r of conn.inflight.values())
|
|
88
117
|
r.destroy();
|
|
118
|
+
if (conn.member)
|
|
119
|
+
closeMemberSession(conn.member);
|
|
89
120
|
conns.delete(c);
|
|
90
121
|
status.clients = conns.size;
|
|
91
122
|
};
|
|
@@ -97,9 +128,12 @@ export async function startRelay(opts) {
|
|
|
97
128
|
/** One tunnelled request, answered by this daemon's own server as a network device's request. */
|
|
98
129
|
const forward = (c, conn, req) => {
|
|
99
130
|
const headers = {};
|
|
131
|
+
// x-kraftwerk-* are the daemon's own marks (relay, member session): never the client's to set.
|
|
100
132
|
for (const [k, v] of Object.entries(req.headers ?? {}))
|
|
101
|
-
if (!DROP_REQUEST.has(k.toLowerCase()))
|
|
133
|
+
if (!DROP_REQUEST.has(k.toLowerCase()) && !k.toLowerCase().startsWith("x-kraftwerk-"))
|
|
102
134
|
headers[k.toLowerCase()] = String(v);
|
|
135
|
+
if (conn.member)
|
|
136
|
+
headers[MEMBER_HEADER] = conn.member;
|
|
103
137
|
const clientHost = typeof req.headers?.host === "string" && /^[A-Za-z0-9.:[\]-]+$/.test(req.headers.host) ? req.headers.host : "relay.invalid";
|
|
104
138
|
// Marked as forwarded: trust.ts never takes a forwarded request for this machine, whatever its peer address.
|
|
105
139
|
Object.assign(headers, { host: `127.0.0.1:${opts.port}`, "x-forwarded-for": "relay", "x-forwarded-host": clientHost, "x-forwarded-proto": "https", "x-kraftwerk-relay": "1" });
|
|
@@ -161,8 +195,45 @@ export async function startRelay(opts) {
|
|
|
161
195
|
dropConn(c);
|
|
162
196
|
return;
|
|
163
197
|
}
|
|
164
|
-
if (msg.t === "
|
|
198
|
+
if (msg.t === "auth") {
|
|
199
|
+
if (conn.member || conn.used) {
|
|
200
|
+
await reply(c, conn, { t: "auth", ok: false, error: "authenticate once, before the first request" });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const r = await openMemberSession(msg.memberToken, msg.workspaceId).catch((err) => ({ ok: false, error: err.message }));
|
|
204
|
+
if (!conns.has(c)) {
|
|
205
|
+
if (r.ok)
|
|
206
|
+
closeMemberSession(r.id);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!r.ok) {
|
|
210
|
+
log(`relay: member refused — ${r.error}`);
|
|
211
|
+
await reply(c, conn, { t: "auth", ok: false, error: r.error });
|
|
212
|
+
send({ t: "close", c, reason: r.error.slice(0, 100) });
|
|
213
|
+
dropConn(c);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
conn.member = r.id;
|
|
217
|
+
await reply(c, conn, { t: "auth", ok: true, member: r.member, workspace: r.workspace });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (msg.t === "req" && typeof msg.id === "number") {
|
|
221
|
+
conn.used = true;
|
|
222
|
+
if (conn.member) {
|
|
223
|
+
// Past the cache's TTL this asks the cloud again; a revoked membership ends the channel.
|
|
224
|
+
const check = await checkMemberSession(conn.member);
|
|
225
|
+
if (!check.ok) {
|
|
226
|
+
await reply(c, conn, { t: "fail", id: msg.id, error: check.error });
|
|
227
|
+
if (check.ended) {
|
|
228
|
+
log(`relay: member session dropped — ${check.error}`);
|
|
229
|
+
send({ t: "close", c, reason: check.error.slice(0, 100) });
|
|
230
|
+
dropConn(c);
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
165
235
|
forward(c, conn, msg);
|
|
236
|
+
}
|
|
166
237
|
else if (msg.t === "abort") {
|
|
167
238
|
const r = conn.inflight.get(msg.id);
|
|
168
239
|
conn.inflight.delete(msg.id);
|
package/dist/server/server.d.ts
CHANGED
|
@@ -34,8 +34,9 @@ export interface OpenWorkspace {
|
|
|
34
34
|
* a standalone `kraftwerk ui` exactly one — its default, also reached
|
|
35
35
|
* without a slug, as before. Each open workspace runs in its own Workspace
|
|
36
36
|
* (core/workspace.ts) with its own .env, background jobs (routines, git
|
|
37
|
-
* sync
|
|
38
|
-
* and leaves the others running.
|
|
37
|
+
* sync), registry entry and live state; closing one ends all of that
|
|
38
|
+
* and leaves the others running. The daemon's hub also carries the
|
|
39
|
+
* machine's cloud record (server/cloud.ts), which lists the open workspaces.
|
|
39
40
|
*/
|
|
40
41
|
export interface Hub {
|
|
41
42
|
readonly server: http.Server;
|
package/dist/server/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import http from "node:http";
|
|
|
3
3
|
import net from "node:net";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { setDefaultWorkspace, Workspace } from "../core/workspace.js";
|
|
6
|
-
import {
|
|
6
|
+
import { stopMachineCloud } from "./cloud.js";
|
|
7
7
|
import { disposeAllBackends, disposeWorkspaceBackends } from "../core/chat/sessions.js";
|
|
8
8
|
import { markWorkspaceStopped, registerInstance, registerWorkspace, unregisterInstance } from "../core/instances.js";
|
|
9
9
|
import { startGitSync, stopGitSync } from "../core/git.js";
|
|
@@ -156,8 +156,8 @@ function installExitHandlers() {
|
|
|
156
156
|
// Stamp first: unregistering forgets which workspaces this process served.
|
|
157
157
|
markWorkspaceStopped();
|
|
158
158
|
unregisterInstance();
|
|
159
|
-
// The cloud goodbye is bounded and a no-op
|
|
160
|
-
void
|
|
159
|
+
// The machine's cloud goodbye is bounded and a no-op outside the daemon, so the exit stays prompt.
|
|
160
|
+
void stopMachineCloud().finally(() => process.exit(sig === "SIGINT" ? 130 : 143));
|
|
161
161
|
});
|
|
162
162
|
}
|
|
163
163
|
process.once("exit", (code) => {
|
|
@@ -258,6 +258,10 @@ export async function startHub(opts) {
|
|
|
258
258
|
return json(res, { error: `no workspace "${target.slug}" here` }, 404);
|
|
259
259
|
const { url } = target;
|
|
260
260
|
const trust = await trustOf(req, url, trustOpts);
|
|
261
|
+
// A member session (members.ts) reaches its one workspace and nothing else: not another, not the hub page.
|
|
262
|
+
if (trust.kind === "member" && open?.slug !== trust.slug) {
|
|
263
|
+
return json(res, { error: `a member session reaches only its workspace — /w/${trust.slug}/…` }, 403);
|
|
264
|
+
}
|
|
261
265
|
const run = (fn) => runInHub(hub, () => (open ? open.ws.run(fn) : fn()));
|
|
262
266
|
if (url.pathname.startsWith("/api/")) {
|
|
263
267
|
const method = req.method ?? "GET";
|
|
@@ -298,6 +302,9 @@ export async function startHub(opts) {
|
|
|
298
302
|
const trust = await trustOf(req, url, trustOpts, true);
|
|
299
303
|
if (trust.kind === "none")
|
|
300
304
|
return void socket.end("HTTP/1.1 401 Unauthorized\r\nconnection: close\r\n\r\n");
|
|
305
|
+
// A socket may name any workspace per message; a member session's scope is per request, so it gets none.
|
|
306
|
+
if (trust.kind === "member")
|
|
307
|
+
return void socket.end("HTTP/1.1 403 Forbidden\r\nconnection: close\r\n\r\n");
|
|
301
308
|
if (url.pathname !== "/api/ws") {
|
|
302
309
|
if (!open)
|
|
303
310
|
return void socket.destroy();
|
|
@@ -345,6 +352,9 @@ export async function startHub(opts) {
|
|
|
345
352
|
await hub.close(o.root);
|
|
346
353
|
opts.onChange = notify;
|
|
347
354
|
hubs.delete(hub);
|
|
355
|
+
// The daemon's machine client (started by `kraftwerk daemon`) says goodbye with the hub it reports.
|
|
356
|
+
if (opts.daemon)
|
|
357
|
+
await stopMachineCloud(hub);
|
|
348
358
|
// A connected browser tab (its socket, a keep-alive connection) must not hold the stop open.
|
|
349
359
|
for (const s of [...sockets])
|
|
350
360
|
s.close();
|
|
@@ -408,7 +418,6 @@ export async function startHub(opts) {
|
|
|
408
418
|
// Best-effort, in the background: none of them may hold up (or, failing, break) the open.
|
|
409
419
|
entry.registered = registerInstance(entry.aliasPort ?? port, abs, opts.daemon ? { hub: true, slug, url: entry.url } : {}).catch(() => { });
|
|
410
420
|
void registerWorkspace(abs).catch(() => { });
|
|
411
|
-
void startCloudSync(entry.url, version).catch(() => { });
|
|
412
421
|
}));
|
|
413
422
|
await noteChange();
|
|
414
423
|
return entry;
|
|
@@ -434,8 +443,6 @@ export async function startHub(opts) {
|
|
|
434
443
|
closeWatches();
|
|
435
444
|
disposeWorkspaceBackends();
|
|
436
445
|
disposeWorkspaceDevs();
|
|
437
|
-
await cloudGoodbye();
|
|
438
|
-
stopCloudSync();
|
|
439
446
|
}));
|
|
440
447
|
await entry.registered;
|
|
441
448
|
unregisterInstance(abs);
|
package/dist/server/trust.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type http from "node:http";
|
|
2
2
|
import { type Device } from "../core/devices.js";
|
|
3
|
+
import { type Member } from "./members.js";
|
|
3
4
|
/**
|
|
4
5
|
* Who is asking. Every request and socket gets one of:
|
|
5
6
|
*
|
|
@@ -17,6 +18,13 @@ import { type Device } from "../core/devices.js";
|
|
|
17
18
|
* - `device`: a paired device's token — `Authorization: Bearer` (apps),
|
|
18
19
|
* the `kw_device` cookie (a phone's browser, set on pairing), or `?token=`
|
|
19
20
|
* on the socket for runtimes whose WebSocket cannot send headers.
|
|
21
|
+
* `relay`: the request came through the relay (server/relay.ts marks
|
|
22
|
+
* it) — a remote caller, who does not see private routines' chats.
|
|
23
|
+
* - `member`: a remote member of ONE workspace, over the relay: the relay
|
|
24
|
+
* verified their member token with the cloud (server/members.ts) and
|
|
25
|
+
* tags each request with the session's random id, which only this
|
|
26
|
+
* process knows. Scoped to that workspace: server.ts refuses every other
|
|
27
|
+
* address, router.ts every machine-level route.
|
|
20
28
|
* - `none`: anyone else. Gets the web UI's shell (to show the pairing
|
|
21
29
|
* screen), the protocol description and the pairing route — nothing else.
|
|
22
30
|
*/
|
|
@@ -25,9 +33,17 @@ export type Trust = {
|
|
|
25
33
|
} | {
|
|
26
34
|
kind: "device";
|
|
27
35
|
device: Device;
|
|
36
|
+
relay?: true;
|
|
37
|
+
} | {
|
|
38
|
+
kind: "member";
|
|
39
|
+
member: Member;
|
|
40
|
+
workspaceId: string;
|
|
41
|
+
slug: string;
|
|
28
42
|
} | {
|
|
29
43
|
kind: "none";
|
|
30
44
|
};
|
|
45
|
+
/** A caller from afar: a member session, or a paired device over the relay. Private routines' chats are not theirs to see. */
|
|
46
|
+
export declare const remoteCaller: (t: Trust) => boolean;
|
|
31
47
|
export declare const DEVICE_COOKIE = "kw_device";
|
|
32
48
|
/** The device token a request carries, if any. `?token=` only counts where asked (the socket). */
|
|
33
49
|
export declare function tokenOf(req: http.IncomingMessage, url: URL, allowQuery: boolean): string | undefined;
|
package/dist/server/trust.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { deviceForToken } from "../core/devices.js";
|
|
2
|
+
import { MEMBER_HEADER, memberSession } from "./members.js";
|
|
3
|
+
/** A caller from afar: a member session, or a paired device over the relay. Private routines' chats are not theirs to see. */
|
|
4
|
+
export const remoteCaller = (t) => t.kind === "member" || (t.kind === "device" && t.relay === true);
|
|
2
5
|
export const DEVICE_COOKIE = "kw_device";
|
|
3
6
|
const LOOPBACK_PEER = /^(127\.|::1$|::ffff:127\.)/;
|
|
4
7
|
const LOOPBACK_NAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
@@ -34,9 +37,17 @@ export function tokenOf(req, url, allowQuery) {
|
|
|
34
37
|
/** The request's trust: this machine (or a trusted network), else a paired device's token, else none. */
|
|
35
38
|
export async function trustOf(req, url, opts, allowQueryToken = false) {
|
|
36
39
|
const peer = req.socket.remoteAddress ?? "";
|
|
40
|
+
// A member session's tag wins over anything else the request says: its scope must hold whatever token rides along.
|
|
41
|
+
const tag = req.headers[MEMBER_HEADER];
|
|
42
|
+
if (typeof tag === "string" && tag) {
|
|
43
|
+
const s = memberSession(tag);
|
|
44
|
+
return s ? { kind: "member", member: s.member, workspaceId: s.workspaceId, slug: s.slug } : { kind: "none" };
|
|
45
|
+
}
|
|
37
46
|
if ((opts.trustLoopback && LOOPBACK_PEER.test(peer) && addressedLocally(req)) || (opts.trustNetwork && !LOOPBACK_PEER.test(peer)))
|
|
38
47
|
return { kind: "local" };
|
|
39
48
|
const token = tokenOf(req, url, allowQueryToken);
|
|
40
49
|
const device = token ? await deviceForToken(token) : null;
|
|
41
|
-
|
|
50
|
+
if (!device)
|
|
51
|
+
return { kind: "none" };
|
|
52
|
+
return req.headers["x-kraftwerk-relay"] === "1" ? { kind: "device", device, relay: true } : { kind: "device", device };
|
|
42
53
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netnodeag/kraftwerk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.78.0",
|
|
4
4
|
"description": "Open-source agentic workspace for teams — where people and AI agents get work done together: agents (Claude Code, Codex, Pi), skills, shared knowledge, repeatable workflows, and deterministic verification",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -177,21 +177,21 @@
|
|
|
177
177
|
"null"
|
|
178
178
|
],
|
|
179
179
|
"additionalProperties": false,
|
|
180
|
-
"description": "kraftwerk cloud
|
|
180
|
+
"description": "kraftwerk cloud: the daemon registers this MACHINE (its relay id) once, and the workspaces it serves ride along. This block only vetoes: `enabled: false` leaves this workspace out of the machine's record. The machine's switch and the cloud's origin are ~/.kraftwerk/cloud.json (`kraftwerk cloud on|off`) and KRAFTWERK_CLOUD_URL; `kraftwerk login` claims the machine.",
|
|
181
181
|
"properties": {
|
|
182
182
|
"enabled": {
|
|
183
183
|
"type": "boolean",
|
|
184
|
-
"description": "false
|
|
184
|
+
"description": "false leaves this workspace out of the machine's cloud record. Default: true"
|
|
185
185
|
},
|
|
186
186
|
"url": {
|
|
187
187
|
"type": "string",
|
|
188
188
|
"pattern": "^https?://",
|
|
189
|
-
"description": "
|
|
189
|
+
"description": "Deprecated: the cloud's origin is the machine's (~/.kraftwerk/cloud.json, KRAFTWERK_CLOUD_URL). Still used, with a warning, when the machine names none."
|
|
190
190
|
},
|
|
191
191
|
"interval": {
|
|
192
192
|
"type": "integer",
|
|
193
193
|
"minimum": 1,
|
|
194
|
-
"description": "
|
|
194
|
+
"description": "Deprecated and ignored: the cloud sets the heartbeat interval."
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
}
|