@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.
Files changed (65) hide show
  1. package/README.md +33 -27
  2. package/dist/cli/cloud.d.ts +2 -0
  3. package/dist/cli/cloud.js +262 -0
  4. package/dist/cli/daemon.js +9 -0
  5. package/dist/cli/doctor.js +16 -8
  6. package/dist/cli/init.js +2 -2
  7. package/dist/cli/invite.d.ts +2 -0
  8. package/dist/cli/invite.js +48 -0
  9. package/dist/cli/kraftwerk.js +7 -0
  10. package/dist/client/relay.d.ts +40 -0
  11. package/dist/client/relay.js +35 -0
  12. package/dist/client/routes.d.ts +5 -0
  13. package/dist/client/routes.js +5 -0
  14. package/dist/config.d.ts +15 -28
  15. package/dist/config.js +5 -19
  16. package/dist/core/chat/channel-turns.d.ts +2 -2
  17. package/dist/core/chat/channel-turns.js +2 -2
  18. package/dist/core/chat/sessions.d.ts +20 -3
  19. package/dist/core/chat/sessions.js +22 -7
  20. package/dist/core/chat/types.d.ts +21 -0
  21. package/dist/core/cloud.d.ts +152 -0
  22. package/dist/core/cloud.js +98 -0
  23. package/dist/core/routines.d.ts +22 -2
  24. package/dist/core/routines.js +26 -0
  25. package/dist/server/api/chats.js +45 -8
  26. package/dist/server/api/cloud.d.ts +86 -0
  27. package/dist/server/api/cloud.js +51 -0
  28. package/dist/server/api/devices.d.ts +15 -9
  29. package/dist/server/api/devices.js +3 -2
  30. package/dist/server/api/index.d.ts +108 -11
  31. package/dist/server/api/index.js +2 -0
  32. package/dist/server/api/router.d.ts +3 -2
  33. package/dist/server/api/router.js +12 -0
  34. package/dist/server/api/workspace.d.ts +16 -1
  35. package/dist/server/api/workspace.js +2 -1
  36. package/dist/server/cloud.d.ts +104 -33
  37. package/dist/server/cloud.js +279 -185
  38. package/dist/server/hub-context.d.ts +1 -1
  39. package/dist/server/hub-context.js +3 -3
  40. package/dist/server/members.d.ts +87 -0
  41. package/dist/server/members.js +117 -0
  42. package/dist/server/relay.d.ts +21 -1
  43. package/dist/server/relay.js +74 -3
  44. package/dist/server/server.d.ts +3 -2
  45. package/dist/server/server.js +13 -6
  46. package/dist/server/trust.d.ts +16 -0
  47. package/dist/server/trust.js +12 -1
  48. package/package.json +1 -1
  49. package/schema/kraftwerk.schema.json +4 -4
  50. package/web/dist/assets/{dist-T1sNLC62.js → dist-B-yVL5lk.js} +1 -1
  51. package/web/dist/assets/{dist-DFXmaDc5.js → dist-B9EMB1Ie.js} +1 -1
  52. package/web/dist/assets/{dist-BQpWYkxm.js → dist-BTc878j7.js} +1 -1
  53. package/web/dist/assets/{dist-fgB7OEJy.js → dist-C90kUzps.js} +1 -1
  54. package/web/dist/assets/{dist-l7foWlFx.js → dist-CpLdAqFQ.js} +1 -1
  55. package/web/dist/assets/{dist-flnLXEzU.js → dist-Crnb8VVO.js} +1 -1
  56. package/web/dist/assets/{dist-vuciRCSz.js → dist-D0rjnN8S.js} +1 -1
  57. package/web/dist/assets/{dist-BTVP-6On.js → dist-D4SDXoGw.js} +1 -1
  58. package/web/dist/assets/{dist-BwIca_Uj.js → dist-DHRhdODt.js} +1 -1
  59. package/web/dist/assets/{dist-BYkcY-Vl.js → dist-DQbifCX0.js} +1 -1
  60. package/web/dist/assets/dist-lSJ-O7kT.js +1 -0
  61. package/web/dist/assets/{editor-C6Ok8NPo.js → editor-DHAdW1g_.js} +3 -3
  62. package/web/dist/assets/{index-z4tpCCzk.js → index-D7JVAjll.js} +5 -5
  63. package/web/dist/index.html +1 -1
  64. package/web/dist-remote/sw.js +1 -1
  65. 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
+ }
@@ -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
  /**
@@ -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 === "req" && typeof msg.id === "number")
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);
@@ -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, cloud), registry entry and live state; closing one ends all of that
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;
@@ -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 { cloudGoodbye, cloudGoodbyeAll, startCloudSync, stopCloudSync } from "./cloud.js";
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 without cloud:, so the exit stays prompt.
160
- void cloudGoodbyeAll().finally(() => process.exit(sig === "SIGINT" ? 130 : 143));
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);
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@netnodeag/kraftwerk",
3
- "version": "0.76.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 manager — ~/.kraftwerk made reachable over the internet. ON BY DEFAULT: every `kraftwerk ui` registers on start and heartbeats while it runs (name, icon, colour, root, machine, version, agent and channel roster). `enabled: false` opts out. No credential belongs here: the cloud hands the instance its identity and a claim code (Settings → Cloud), and an unattended server uses KRAFTWERK_CLOUD_TOKEN in .env.",
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 opts out of the cloud. Default: true"
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": "Origin of the cloud manager. Default: https://srv.kraftwerk-cloud.netnode.cloud (the KRAFTWERK_CLOUD_URL environment variable wins; \"off\" disables)"
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": "Seconds between heartbeats. Default: 60 (the cloud may raise it)"
194
+ "description": "Deprecated and ignored: the cloud sets the heartbeat interval."
195
195
  }
196
196
  }
197
197
  }