@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
package/dist/server/cloud.js
CHANGED
|
@@ -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
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
46
|
+
return { list, ymlUrls };
|
|
44
47
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
if (!
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
if (!c.identity || c.status.state === "off")
|
|
130
|
+
if (!m.creds || !m.registered)
|
|
123
131
|
return;
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
...
|
|
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(
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
155
|
-
|
|
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 =
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
186
|
+
m.timer.unref();
|
|
172
187
|
}
|
|
173
188
|
/**
|
|
174
|
-
* Start the client
|
|
175
|
-
*
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
/**
|
|
202
|
-
export function
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
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
|
|
215
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
/**
|
|
225
|
-
export function
|
|
226
|
-
for (const
|
|
227
|
-
ws.
|
|
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
|
-
/**
|
|
230
|
-
|
|
231
|
-
|
|
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;
|