@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
@@ -1,232 +1,326 @@
1
- import { createHash } from "node:crypto";
2
1
  import { promises as fs } from "node:fs";
3
2
  import os from "node:os";
4
- import path from "node:path";
5
- import { cloudFor, resolveWorkspace } from "../config.js";
6
- import { workspaceRoot } from "../core/context.js";
7
- import { perWorkspace, workspaceOpen } from "../core/workspace.js";
8
- import { listAgents, toSummary } from "../core/agents.js";
9
- import { listChannels, toChannelSummary } from "../core/channels.js";
10
- import { tildify } from "../core/instances.js";
11
- const claimUrlFor = (url, code) => (code ? `${url}/claim/${encodeURIComponent(code)}` : null);
12
- const REQUEST_TIMEOUT_MS = 8_000;
13
- const GOODBYE_TIMEOUT_MS = 800;
14
- /** The client's state, one per workspace: each served workspace registers and heartbeats on its own. */
15
- const client = perWorkspace(() => ({
16
- status: { url: "", state: "off" },
17
- identity: null,
3
+ import { resolveWorkspace } from "../config.js";
4
+ import { claimUrlFor, machineCloud, machineGoodbye, machineHeartbeat, machineRegister, machineUnlink, MAX_REPORTED_ROUTINES, retiredCloudDir, unknownMachine, workspaceInvite, } from "../core/cloud.js";
5
+ import { identityString, readIdentity } from "../core/identity.js";
6
+ import { routineReport } from "../core/routines.js";
7
+ import { machineCredentials } from "./relay.js";
8
+ const DEFAULT_INTERVAL = 60;
9
+ const STANDALONE = { url: "", state: "off", reason: "only the daemon registers this machine with the cloud (`kraftwerk daemon start`)" };
10
+ const m = {
11
+ status: { ...STANDALONE },
12
+ creds: null,
13
+ registered: false,
18
14
  timer: null,
19
- stopped: false,
20
- lastRoster: "",
21
- /** Where this workspace is reached on this machine, as the cloud lists it. */
22
- selfUrl: "",
23
- selfVersion: "",
24
- startedAt: new Date().toISOString(),
25
- }));
26
- export const cloudStatus = () => client().status;
27
- const cloudDir = () => path.join(os.homedir(), ".kraftwerk", "cloud");
28
- const identityFile = (url, root) => path.join(cloudDir(), `${createHash("sha1").update(`${url}\n${root}`).digest("hex").slice(0, 16)}.json`);
29
- async function readIdentity(file) {
30
- try {
31
- const rec = JSON.parse(await fs.readFile(file, "utf8"));
32
- return typeof rec.id === "string" && typeof rec.secret === "string" ? { id: rec.id, secret: rec.secret } : null;
33
- }
34
- catch {
35
- return null;
36
- }
15
+ started: false,
16
+ owner: null,
17
+ source: (() => []),
18
+ version: "",
19
+ /** The workspace list the cloud last acknowledged, as JSON: sent again only when it differs. */
20
+ reported: "",
21
+ /** The routine report the cloud last took, as JSON (claimed only; "" sends it again). */
22
+ routines: "",
23
+ /** One round at a time: a timer tick, a sync from the CLI and a workspace change never overlap. */
24
+ lock: Promise.resolve(),
25
+ warned: new Set(),
26
+ };
27
+ function serial(fn) {
28
+ const run = m.lock.then(fn, fn);
29
+ m.lock = run.catch(() => { });
30
+ return run;
37
31
  }
38
- async function writeIdentity(file, url, root, id) {
39
- try {
40
- await fs.mkdir(cloudDir(), { recursive: true });
41
- await fs.writeFile(file, JSON.stringify({ ...id, url, root, updatedAt: new Date().toISOString() }), { mode: 0o600 });
32
+ export const cloudStatus = () => m.status;
33
+ /** The open workspaces as the cloud lists them: vetoed ones left out, roots only once claimed. */
34
+ async function entries(withRoots) {
35
+ const list = [];
36
+ const ymlUrls = [];
37
+ for (const o of m.source()) {
38
+ const ws = await resolveWorkspace(o.root).catch(() => null);
39
+ const c = ws?.config.cloud;
40
+ if (c?.enabled === false)
41
+ continue;
42
+ if (c?.url)
43
+ ymlUrls.push(c.url.replace(/\/+$/, ""));
44
+ list.push({ ...(ws?.config.id ? { id: ws.config.id } : {}), name: o.name, slug: o.slug, ...(withRoots ? { root: o.root } : {}) });
42
45
  }
43
- catch { } // best-effort — the next start registers again
46
+ return { list, ymlUrls };
44
47
  }
45
- /** The agents and channels of this workspace, as the cloud lists them. */
46
- async function roster() {
47
- const [agents, channels] = await Promise.all([
48
- listAgents().then((l) => l.map(toSummary)).catch(() => []),
49
- listChannels().then((l) => l.map(toChannelSummary)).catch(() => []),
50
- ]);
51
- return { agents, channels };
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);
52
68
  }
53
- async function post(url, body, timeout = REQUEST_TIMEOUT_MS) {
54
- const c = client();
55
- const r = await fetch(url, {
56
- method: "POST",
57
- headers: { "content-type": "application/json", "user-agent": `kraftwerk/${c.selfVersion}` },
58
- body: JSON.stringify(body),
59
- signal: AbortSignal.timeout(timeout),
60
- });
61
- const text = await r.text();
62
- let data;
63
- try {
64
- data = text ? JSON.parse(text) : {};
69
+ function warnYml(cloud, ymlUrls) {
70
+ if (!ymlUrls.length)
71
+ return;
72
+ const key = `${cloud.source}:${ymlUrls[0]}`;
73
+ if (m.warned.has(key))
74
+ return;
75
+ m.warned.add(key);
76
+ if (cloud.source === "kraftwerk.yml") {
77
+ console.warn(`⚠ kraftwerk.yml cloud.url is deprecated — the cloud is the machine's now: \`kraftwerk cloud on --url ${cloud.url}\` (using ${cloud.url} for now)`);
65
78
  }
66
- catch {
67
- throw new Error(`${r.status} from ${url}: not JSON`);
79
+ else {
80
+ console.warn(`⚠ kraftwerk.yml cloud.url (${ymlUrls[0]}) is ignored: the machine's cloud is ${cloud.url} (${cloud.source}); remove it from kraftwerk.yml`);
68
81
  }
69
- if (!r.ok)
70
- throw new Error(data.error ?? `${r.status} from ${url}`);
71
- return data;
72
82
  }
73
- /** Register (or re-register) this instance. Returns false when the cloud refused or is unreachable. */
74
83
  async function register() {
75
- const c = client();
76
- const project = await resolveWorkspace(workspaceRoot()).catch(() => null);
77
- const cloud = project ? cloudFor(project) : undefined;
78
- if (!project || !cloud)
84
+ const { list, ymlUrls } = await entries(false);
85
+ const cloud = await machineCloud(ymlUrls);
86
+ warnYml(cloud, ymlUrls);
87
+ if (!cloud.enabled) {
88
+ m.status = { url: cloud.url, state: "off", source: cloud.source, ...(cloud.reason ? { reason: cloud.reason } : {}) };
79
89
  return false;
80
- const file = identityFile(cloud.url, project.root);
81
- c.identity ??= await readIdentity(file);
82
- const { agents, channels } = await roster();
83
- c.lastRoster = JSON.stringify({ agents, channels });
84
- const body = {
85
- ...(c.identity ?? {}),
86
- name: project.config.name ?? path.basename(project.root),
87
- icon: project.config.icon ?? "",
88
- color: project.config.color ?? "",
89
- root: project.root,
90
- rootLabel: tildify(project.root),
91
- hostname: os.hostname(),
92
- platform: `${process.platform}-${process.arch}`,
93
- version: c.selfVersion,
94
- url: c.selfUrl,
95
- // Part of the cloud's register contract; always "" since the Cloudflare Tunnel was removed (0.65).
96
- publicUrl: "",
97
- agents,
98
- channels,
99
- interval: cloud.interval,
100
- startedAt: c.startedAt,
101
- ...(cloud.token ? { accountToken: cloud.token } : {}),
102
- };
90
+ }
91
+ const identity = await readIdentity();
103
92
  try {
104
- const r = await post(`${cloud.url}/api/instances/register`, body);
105
- if (!c.identity || c.identity.id !== r.id || c.identity.secret !== r.secret) {
106
- c.identity = { id: r.id, secret: r.secret };
107
- await writeIdentity(file, cloud.url, project.root, c.identity);
108
- }
109
- c.status = {
110
- url: cloud.url, state: "connected", id: r.id, account: r.account, interval: r.interval, lastSeen: new Date().toISOString(),
111
- claimCode: r.claimCode ?? null, claimUrl: claimUrlFor(cloud.url, r.claimCode),
93
+ m.creds ??= await machineCredentials();
94
+ }
95
+ catch (err) {
96
+ m.status = { url: cloud.url, source: cloud.source, state: "error", error: err.message };
97
+ return false;
98
+ }
99
+ const base = { url: cloud.url, source: cloud.source, id: m.creds.id, machine: identityString(identity) };
100
+ if (m.status.state === "off" || m.status.url !== cloud.url)
101
+ m.status = { ...base, state: "connecting" };
102
+ try {
103
+ const r = await machineRegister(cloud.url, m.creds, {
104
+ name: identity.machine,
105
+ handle: identity.handle,
106
+ hostname: os.hostname(),
107
+ platform: `${process.platform}-${process.arch}`,
108
+ version: m.version,
109
+ }, list);
110
+ m.registered = true;
111
+ m.routines = "";
112
+ // An unclaimed register carries no roots: the list counts as reported only when that is all the cloud should know.
113
+ m.reported = r.account ? "" : JSON.stringify(list);
114
+ m.status = {
115
+ ...base, state: "connected", account: r.account ?? null, interval: r.interval || DEFAULT_INTERVAL, lastSeen: new Date().toISOString(),
116
+ claimCode: r.claimCode ?? null, claimUrl: claimUrlFor(cloud.url, r.claimCode), workspaces: list.length,
112
117
  };
118
+ // Already claimed: the roots follow at once.
119
+ if (claimed())
120
+ await heartbeat();
113
121
  return true;
114
122
  }
115
123
  catch (err) {
116
- c.status = { url: cloud.url, state: "error", error: err.message, ...(c.identity ? { id: c.identity.id } : {}) };
124
+ m.registered = false;
125
+ m.status = { ...m.status, ...base, state: "error", error: err.message };
117
126
  return false;
118
127
  }
119
128
  }
120
129
  async function heartbeat() {
121
- const c = client();
122
- if (!c.identity || c.status.state === "off")
130
+ if (!m.creds || !m.registered)
123
131
  return;
124
- const { agents, channels } = await roster();
125
- const rosterNow = JSON.stringify({ agents, channels });
126
- const body = {
127
- ...c.identity,
128
- version: c.selfVersion,
129
- ...(rosterNow !== c.lastRoster ? { agents, channels } : {}),
130
- };
132
+ const wasClaimed = claimed();
133
+ const { list } = await entries(wasClaimed);
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) : "";
131
138
  try {
132
- const r = await post(`${c.status.url}/api/instances/heartbeat`, body);
133
- c.lastRoster = rosterNow;
134
- c.status = {
135
- ...c.status, state: "connected", error: undefined, interval: r.interval, lastSeen: new Date().toISOString(),
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
+ });
144
+ m.reported = now;
145
+ m.routines = ran;
146
+ m.status = {
147
+ ...m.status, state: "connected", error: undefined, interval: r.interval || m.status.interval || DEFAULT_INTERVAL, lastSeen: new Date().toISOString(),
136
148
  ...(r.account !== undefined ? { account: r.account } : {}),
137
- ...(r.claimCode !== undefined ? { claimCode: r.claimCode, claimUrl: claimUrlFor(c.status.url, r.claimCode) } : {}),
149
+ ...(r.claimCode !== undefined ? { claimCode: r.claimCode, claimUrl: claimUrlFor(m.status.url, r.claimCode) } : {}),
150
+ workspaces: list.length,
138
151
  };
152
+ // Just claimed: the roots go out now, not a whole interval later.
153
+ if (!wasClaimed && claimed())
154
+ await heartbeat();
139
155
  }
140
156
  catch (err) {
141
- const message = err.message;
142
- c.status = { ...c.status, state: "error", error: message };
143
- // An unknown identity (the cloud lost it) is fixed by registering again, which hands out a new one.
144
- if (/unknown instance|wrong instance secret|id and secret required/.test(message)) {
145
- c.identity = null;
146
- // Not after a goodbye: a stopped client registers nothing again.
147
- if (!c.stopped && workspaceOpen())
157
+ m.status = { ...m.status, state: "error", error: err.message };
158
+ if (unknownMachine(err)) {
159
+ m.registered = false;
160
+ if (m.started)
148
161
  await register();
149
162
  }
150
163
  }
151
164
  }
152
- /** Schedule the next tick after the effective interval; a failed cloud is retried at the same cadence. */
165
+ /** One round: register when not registered, else heartbeat. */
166
+ const round = () => serial(async () => {
167
+ if (!m.started)
168
+ return;
169
+ if (m.registered)
170
+ await heartbeat();
171
+ else
172
+ await register();
173
+ });
153
174
  function schedule() {
154
- const c = client();
155
- if (c.stopped)
175
+ if (m.timer)
176
+ clearTimeout(m.timer);
177
+ m.timer = null;
178
+ if (!m.started || m.status.state === "off")
156
179
  return;
157
- const seconds = c.status.interval && c.status.interval > 0 ? c.status.interval : 60;
158
- c.timer = setTimeout(async () => {
159
- // Stopped (or the workspace closed) while this timer waited, or while the call below ran: no next round.
160
- if (c.stopped || !workspaceOpen())
161
- return;
162
- if (c.status.state === "connected" || c.status.state === "error") {
163
- if (c.identity)
164
- await heartbeat();
165
- else
166
- await register();
167
- }
168
- if (!c.stopped && workspaceOpen())
169
- schedule();
180
+ const seconds = m.status.interval && m.status.interval > 0 ? m.status.interval : DEFAULT_INTERVAL;
181
+ m.timer = setTimeout(async () => {
182
+ m.timer = null;
183
+ await round();
184
+ schedule();
170
185
  }, seconds * 1000);
171
- c.timer.unref();
186
+ m.timer.unref();
172
187
  }
173
188
  /**
174
- * Start the client once the inspector listens. Resolves after the first
175
- * registration attempt (success or not); the heartbeat loop runs on its
176
- * own afterwards.
189
+ * Start the machine client (the daemon, once its workspaces are open).
190
+ * Resolves after the first registration attempt; the heartbeat runs on its
191
+ * own afterwards. A second start in the same process is ignored.
177
192
  */
178
- export async function startCloudSync(url, version) {
179
- const c = client();
180
- c.selfUrl = url;
181
- c.selfVersion = version;
182
- c.stopped = false;
183
- const project = await resolveWorkspace(workspaceRoot()).catch(() => null);
184
- const cloud = project ? cloudFor(project) : undefined;
185
- if (!cloud) {
186
- c.status = { url: "", state: "off" };
187
- return;
193
+ export async function startMachineCloud(opts) {
194
+ if (m.started)
195
+ return m.status;
196
+ m.started = true;
197
+ m.owner = opts.owner ?? null;
198
+ m.source = opts.workspaces;
199
+ m.version = opts.version;
200
+ m.status = { url: "", state: "off" };
201
+ // The per-workspace identities of before (one record per workspace) are retired with the machine record.
202
+ await fs.rm(retiredCloudDir(), { recursive: true, force: true }).catch(() => { });
203
+ // The machine's id exists from the daemon's first start on, with the relay still off.
204
+ m.creds = await machineCredentials().catch(() => null);
205
+ await serial(register);
206
+ schedule();
207
+ return m.status;
208
+ }
209
+ /**
210
+ * Settings changed (`kraftwerk cloud on|off`, a URL): turn the client on or
211
+ * off to match; off says goodbye first. Without a started client (a
212
+ * standalone server) nothing happens.
213
+ */
214
+ export async function applyMachineCloud() {
215
+ if (!m.started)
216
+ return m.status;
217
+ const cloud = await machineCloud((await entries(false)).ymlUrls);
218
+ if (!cloud.enabled || (m.registered && cloud.url !== m.status.url)) {
219
+ await serial(async () => {
220
+ if (m.registered && m.creds)
221
+ await machineGoodbye(m.status.url, m.creds, m.version).catch(() => { });
222
+ m.registered = false;
223
+ m.reported = "";
224
+ });
188
225
  }
189
- // The workspace may close (or the client stop) while this waits: then it does not register or schedule at all.
190
- if (c.stopped || !workspaceOpen())
191
- return;
192
- c.status = { url: cloud.url, state: "connecting", interval: cloud.interval };
193
- await register();
194
- if (c.stopped || !workspaceOpen())
195
- return;
196
- // Until the cloud answers, retry at the configured cadence; once it does, at what it asked for.
197
- if (c.status.state !== "connected")
198
- c.status.interval = cloud.interval;
226
+ if (!cloud.enabled) {
227
+ if (m.timer)
228
+ clearTimeout(m.timer);
229
+ m.timer = null;
230
+ m.status = { url: cloud.url, state: "off", source: cloud.source, ...(cloud.reason ? { reason: cloud.reason } : {}) };
231
+ return m.status;
232
+ }
233
+ if (!m.registered)
234
+ await serial(register);
235
+ schedule();
236
+ return m.status;
237
+ }
238
+ /** A round now (the CLI's `login` waits for a claim): registers when needed, else heartbeats. */
239
+ export async function syncMachineCloud() {
240
+ if (!m.started)
241
+ return m.status;
242
+ if (m.status.state === "off")
243
+ return applyMachineCloud();
244
+ await round();
199
245
  schedule();
246
+ return m.status;
200
247
  }
201
- /** Stop the heartbeat loop (server close, tests). */
202
- export function stopCloudSync() {
203
- const c = client();
204
- c.stopped = true;
205
- if (c.timer)
206
- clearTimeout(c.timer);
207
- c.timer = null;
248
+ /** Release the claim (`kraftwerk logout`): the cloud hands out a fresh claim code. */
249
+ export async function unlinkMachineCloud() {
250
+ if (!m.started || m.status.state === "off")
251
+ return m.status;
252
+ return serial(async () => {
253
+ if (!m.registered)
254
+ await register();
255
+ if (!m.creds || !m.registered)
256
+ throw new Error(m.status.error ?? "not registered with the cloud");
257
+ const r = await machineUnlink(m.status.url, m.creds, m.version);
258
+ // The next heartbeat sends the list again — without roots now.
259
+ m.reported = "";
260
+ m.routines = "";
261
+ m.status = { ...m.status, account: null, claimCode: r.claimCode ?? null, claimUrl: claimUrlFor(m.status.url, r.claimCode) };
262
+ return m.status;
263
+ });
208
264
  }
209
265
  /**
210
- * Tell the cloud we are going away, so the record flips to "stopped" now
211
- * instead of after the grace period. Bounded: an exit handler waits for
212
- * this, and a slow cloud must not hold the process.
266
+ * Stop the client and tell the cloud the machine is going away (bounded:
267
+ * an exit handler waits for this). `owner`: only the client that owner
268
+ * started (a hub stopping in a test leaves another's alone).
213
269
  */
214
- export async function cloudGoodbye() {
215
- const c = client();
216
- stopCloudSync();
217
- if (!c.identity || c.status.state === "off" || !c.status.url)
270
+ export async function stopMachineCloud(owner) {
271
+ if (!m.started || (owner !== undefined && owner !== m.owner))
218
272
  return;
219
- try {
220
- await post(`${c.status.url}/api/instances/goodbye`, c.identity, GOODBYE_TIMEOUT_MS);
221
- }
222
- catch { }
273
+ m.started = false;
274
+ if (m.timer)
275
+ clearTimeout(m.timer);
276
+ m.timer = null;
277
+ const { creds, registered } = m;
278
+ const url = m.status.url;
279
+ m.registered = false;
280
+ m.reported = "";
281
+ m.status = { ...STANDALONE };
282
+ if (registered && creds && url)
283
+ await machineGoodbye(url, creds, m.version).catch(() => { });
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 };
223
294
  }
224
- /** Every served workspace: stop its heartbeat (the server closes). */
225
- export function stopAllCloudSync() {
226
- for (const [ws] of client.entries())
227
- ws.run(stopCloudSync);
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;
228
303
  }
229
- /** Every served workspace says goodbye, in parallel (still bounded per workspace). */
230
- export async function cloudGoodbyeAll() {
231
- await Promise.all(client.entries().map(([ws]) => ws.run(cloudGoodbye)));
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 } };
232
326
  }
@@ -1,7 +1,7 @@
1
1
  import type { Hub } from "./server.js";
2
2
  export declare const runInHub: <T>(hub: Hub, fn: () => T) => T;
3
3
  export declare function currentHub(): Hub;
4
- /** API paths that need no workspace: the machine's own (the hub, devices and pairing, the protocol, the socket). */
4
+ /** API paths that need no workspace: the machine's own (the hub, devices and pairing, the cloud record, the protocol, the socket). */
5
5
  export declare const MACHINE_PATHS: RegExp;
6
6
  /** Route names callable over the socket without a workspace. */
7
7
  export declare const MACHINE_ROUTES: RegExp;
@@ -12,7 +12,7 @@ export function currentHub() {
12
12
  throw new Error("no hub here");
13
13
  return hub;
14
14
  }
15
- /** API paths that need no workspace: the machine's own (the hub, devices and pairing, the protocol, the socket). */
16
- export const MACHINE_PATHS = /^\/api\/(hub(\/|$)|pair$|devices(\/|$)|protocol$|ws$)/;
15
+ /** API paths that need no workspace: the machine's own (the hub, devices and pairing, the cloud record, the protocol, the socket). */
16
+ export const MACHINE_PATHS = /^\/api\/(hub(\/|$)|pair$|devices(\/|$)|cloud(\/|$)|protocol$|ws$)/;
17
17
  /** Route names callable over the socket without a workspace. */
18
- export const MACHINE_ROUTES = /^(hub|devices|protocol)\./;
18
+ export const MACHINE_ROUTES = /^(hub|devices|cloud|protocol)\./;
@@ -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;