@netnodeag/kraftwerk 0.77.0 → 0.78.1

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 (51) 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/cli/remote.js +1 -1
  5. package/dist/client/relay.d.ts +40 -0
  6. package/dist/client/relay.js +35 -0
  7. package/dist/client/routes.d.ts +1 -0
  8. package/dist/client/routes.js +1 -0
  9. package/dist/core/chat/channel-turns.d.ts +2 -2
  10. package/dist/core/chat/channel-turns.js +2 -2
  11. package/dist/core/chat/sessions.d.ts +20 -3
  12. package/dist/core/chat/sessions.js +22 -7
  13. package/dist/core/chat/types.d.ts +21 -0
  14. package/dist/core/cloud.d.ts +38 -1
  15. package/dist/core/cloud.js +4 -0
  16. package/dist/core/routines.d.ts +22 -2
  17. package/dist/core/routines.js +26 -0
  18. package/dist/server/api/chats.js +45 -8
  19. package/dist/server/api/cloud.d.ts +16 -2
  20. package/dist/server/api/cloud.js +15 -2
  21. package/dist/server/api/devices.d.ts +7 -1
  22. package/dist/server/api/devices.js +3 -2
  23. package/dist/server/api/index.d.ts +20 -1
  24. package/dist/server/api/router.d.ts +3 -2
  25. package/dist/server/api/router.js +12 -0
  26. package/dist/server/cloud.d.ts +42 -3
  27. package/dist/server/cloud.js +77 -2
  28. package/dist/server/members.d.ts +87 -0
  29. package/dist/server/members.js +117 -0
  30. package/dist/server/relay.d.ts +15 -2
  31. package/dist/server/relay.js +58 -6
  32. package/dist/server/server.js +7 -0
  33. package/dist/server/trust.d.ts +16 -0
  34. package/dist/server/trust.js +12 -1
  35. package/package.json +2 -2
  36. package/web/dist/assets/{dist-BGHweZKi.js → dist-B-yVL5lk.js} +1 -1
  37. package/web/dist/assets/{dist-BVPxOqcx.js → dist-B9EMB1Ie.js} +1 -1
  38. package/web/dist/assets/{dist-DT2Tjvwx.js → dist-BTc878j7.js} +1 -1
  39. package/web/dist/assets/{dist-DoD3qX1P.js → dist-C90kUzps.js} +1 -1
  40. package/web/dist/assets/{dist-DCFREMXk.js → dist-CpLdAqFQ.js} +1 -1
  41. package/web/dist/assets/{dist-CXoAMU1b.js → dist-Crnb8VVO.js} +1 -1
  42. package/web/dist/assets/{dist-FFA4-S6d.js → dist-D0rjnN8S.js} +1 -1
  43. package/web/dist/assets/{dist-yGx0qYbw.js → dist-D4SDXoGw.js} +1 -1
  44. package/web/dist/assets/{dist-DdA5PoVJ.js → dist-DHRhdODt.js} +1 -1
  45. package/web/dist/assets/{dist-CCNyRSAA.js → dist-DQbifCX0.js} +1 -1
  46. package/web/dist/assets/dist-lSJ-O7kT.js +1 -0
  47. package/web/dist/assets/{editor-CyWapLQr.js → editor-DHAdW1g_.js} +3 -3
  48. package/web/dist/assets/{index-CNXcNoKJ.js → index-D7JVAjll.js} +4 -4
  49. package/web/dist/index.html +1 -1
  50. package/web/dist-remote/sw.js +1 -1
  51. package/web/dist/assets/dist-zjUUP5a9.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
+ }
@@ -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
@@ -42,7 +49,13 @@ export interface RelayStatus {
42
49
  since?: string;
43
50
  }
44
51
  export declare function readRelaySettings(): Promise<RelaySettings | null>;
45
- /** Turn the relay on or off (creating the machine's key on first use); `url` switches relays. */
52
+ /**
53
+ * Turn the relay on or off (creating the machine's key on first use).
54
+ * `url` switches relays explicitly and sticks across off/on. Without it,
55
+ * turning ON re-derives the URL only while the stored one is the untouched
56
+ * default — a machine whose cloud is elsewhere (env, cloud.json) then
57
+ * relays through that cloud, while an explicit `--url` choice is kept.
58
+ */
46
59
  export declare function setRelay(enabled: boolean, url?: string): Promise<RelaySettings>;
47
60
  /**
48
61
  * The machine's key pair and secret, created (with the relay staying off)
@@ -5,7 +5,9 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { daemonHandshake, exportKeyPair, generateKeyPair, importKeyPair, relayId, toBase64, fromBase64, fromBase64Url, } from "../client/relay.js";
7
7
  import { CLOUD_DEFAULT_URL } from "../config.js";
8
+ import { machineCloud } from "../core/cloud.js";
8
9
  import { dropPushToken } from "../core/devices.js";
10
+ import { checkMemberSession, closeMemberSession, MEMBER_HEADER, openMemberSession } from "./members.js";
9
11
  const settingsFile = () => path.join(os.homedir(), ".kraftwerk", "relay.json");
10
12
  export async function readRelaySettings() {
11
13
  try {
@@ -25,14 +27,22 @@ async function writeRelaySettings(s) {
25
27
  await fs.writeFile(tmp, JSON.stringify(s, null, 2), { mode: 0o600 });
26
28
  await fs.rename(tmp, file);
27
29
  }
28
- /** Turn the relay on or off (creating the machine's key on first use); `url` switches relays. */
30
+ /**
31
+ * Turn the relay on or off (creating the machine's key on first use).
32
+ * `url` switches relays explicitly and sticks across off/on. Without it,
33
+ * turning ON re-derives the URL only while the stored one is the untouched
34
+ * default — a machine whose cloud is elsewhere (env, cloud.json) then
35
+ * relays through that cloud, while an explicit `--url` choice is kept.
36
+ */
29
37
  export async function setRelay(enabled, url) {
30
38
  let s = await readRelaySettings();
39
+ const follow = !url && enabled && (!s || s.url === CLOUD_DEFAULT_URL);
40
+ const effective = url ?? (follow ? (await machineCloud()).url : undefined);
31
41
  if (!s) {
32
42
  const pair = await exportKeyPair(await generateKeyPair());
33
- s = { enabled, url: url || CLOUD_DEFAULT_URL, ...pair, secret: randomBytes(32).toString("base64url") };
43
+ s = { enabled, url: effective || CLOUD_DEFAULT_URL, ...pair, secret: randomBytes(32).toString("base64url") };
34
44
  }
35
- s = { ...s, enabled, ...(url ? { url: url.replace(/\/+$/, "") } : {}) };
45
+ s = { ...s, enabled, ...(effective ? { url: effective.replace(/\/+$/, "") } : {}) };
36
46
  await writeRelaySettings(s);
37
47
  return s;
38
48
  }
@@ -55,7 +65,7 @@ export async function ensureRelayKeys() {
55
65
  throw err;
56
66
  }
57
67
  const pair = await exportKeyPair(await generateKeyPair());
58
- const fresh = { enabled: false, url: CLOUD_DEFAULT_URL, ...pair, secret: randomBytes(32).toString("base64url") };
68
+ const fresh = { enabled: false, url: (await machineCloud()).url, ...pair, secret: randomBytes(32).toString("base64url") };
59
69
  await writeRelaySettings(fresh);
60
70
  return fresh;
61
71
  }
@@ -114,6 +124,8 @@ export async function startRelay(opts) {
114
124
  return;
115
125
  for (const r of conn.inflight.values())
116
126
  r.destroy();
127
+ if (conn.member)
128
+ closeMemberSession(conn.member);
117
129
  conns.delete(c);
118
130
  status.clients = conns.size;
119
131
  };
@@ -125,9 +137,12 @@ export async function startRelay(opts) {
125
137
  /** One tunnelled request, answered by this daemon's own server as a network device's request. */
126
138
  const forward = (c, conn, req) => {
127
139
  const headers = {};
140
+ // x-kraftwerk-* are the daemon's own marks (relay, member session): never the client's to set.
128
141
  for (const [k, v] of Object.entries(req.headers ?? {}))
129
- if (!DROP_REQUEST.has(k.toLowerCase()))
142
+ if (!DROP_REQUEST.has(k.toLowerCase()) && !k.toLowerCase().startsWith("x-kraftwerk-"))
130
143
  headers[k.toLowerCase()] = String(v);
144
+ if (conn.member)
145
+ headers[MEMBER_HEADER] = conn.member;
131
146
  const clientHost = typeof req.headers?.host === "string" && /^[A-Za-z0-9.:[\]-]+$/.test(req.headers.host) ? req.headers.host : "relay.invalid";
132
147
  // Marked as forwarded: trust.ts never takes a forwarded request for this machine, whatever its peer address.
133
148
  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" });
@@ -189,8 +204,45 @@ export async function startRelay(opts) {
189
204
  dropConn(c);
190
205
  return;
191
206
  }
192
- if (msg.t === "req" && typeof msg.id === "number")
207
+ if (msg.t === "auth") {
208
+ if (conn.member || conn.used) {
209
+ await reply(c, conn, { t: "auth", ok: false, error: "authenticate once, before the first request" });
210
+ return;
211
+ }
212
+ const r = await openMemberSession(msg.memberToken, msg.workspaceId).catch((err) => ({ ok: false, error: err.message }));
213
+ if (!conns.has(c)) {
214
+ if (r.ok)
215
+ closeMemberSession(r.id);
216
+ return;
217
+ }
218
+ if (!r.ok) {
219
+ log(`relay: member refused — ${r.error}`);
220
+ await reply(c, conn, { t: "auth", ok: false, error: r.error });
221
+ send({ t: "close", c, reason: r.error.slice(0, 100) });
222
+ dropConn(c);
223
+ return;
224
+ }
225
+ conn.member = r.id;
226
+ await reply(c, conn, { t: "auth", ok: true, member: r.member, workspace: r.workspace });
227
+ return;
228
+ }
229
+ if (msg.t === "req" && typeof msg.id === "number") {
230
+ conn.used = true;
231
+ if (conn.member) {
232
+ // Past the cache's TTL this asks the cloud again; a revoked membership ends the channel.
233
+ const check = await checkMemberSession(conn.member);
234
+ if (!check.ok) {
235
+ await reply(c, conn, { t: "fail", id: msg.id, error: check.error });
236
+ if (check.ended) {
237
+ log(`relay: member session dropped — ${check.error}`);
238
+ send({ t: "close", c, reason: check.error.slice(0, 100) });
239
+ dropConn(c);
240
+ }
241
+ return;
242
+ }
243
+ }
193
244
  forward(c, conn, msg);
245
+ }
194
246
  else if (msg.t === "abort") {
195
247
  const r = conn.inflight.get(msg.id);
196
248
  conn.inflight.delete(msg.id);
@@ -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();
@@ -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;
@@ -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
- return device ? { kind: "device", device } : { kind: "none" };
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,7 +1,7 @@
1
1
  {
2
2
  "name": "@netnodeag/kraftwerk",
3
- "version": "0.77.0",
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",
3
+ "version": "0.78.1",
4
+ "description": "Open-source agentic workspace for teams \u2014 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",
7
7
  "workflow",