@hubfluencer/mcp 0.1.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/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/index.js +30790 -0
- package/dist/login.js +377 -0
- package/package.json +51 -0
- package/src/client.ts +206 -0
- package/src/core.ts +244 -0
- package/src/credentials.ts +48 -0
- package/src/index.ts +2310 -0
- package/src/login.ts +129 -0
- package/src/uploads.ts +369 -0
- package/tsconfig.json +19 -0
package/dist/login.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __toESM = (mod, isNodeMode, target) => {
|
|
8
|
+
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
9
|
+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
10
|
+
for (let key of __getOwnPropNames(mod))
|
|
11
|
+
if (!__hasOwnProp.call(to, key))
|
|
12
|
+
__defProp(to, key, {
|
|
13
|
+
get: () => mod[key],
|
|
14
|
+
enumerable: true
|
|
15
|
+
});
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
19
|
+
var __export = (target, all) => {
|
|
20
|
+
for (var name in all)
|
|
21
|
+
__defProp(target, name, {
|
|
22
|
+
get: all[name],
|
|
23
|
+
enumerable: true,
|
|
24
|
+
configurable: true,
|
|
25
|
+
set: (newValue) => all[name] = () => newValue
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// src/login.ts
|
|
30
|
+
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
|
31
|
+
import { dirname } from "node:path";
|
|
32
|
+
|
|
33
|
+
// src/core.ts
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
36
|
+
function isPrivateOrMetadataHost(hostname) {
|
|
37
|
+
let host = hostname.trim().toLowerCase();
|
|
38
|
+
if (host.startsWith("[") && host.endsWith("]"))
|
|
39
|
+
host = host.slice(1, -1);
|
|
40
|
+
const zone = host.indexOf("%");
|
|
41
|
+
if (zone !== -1)
|
|
42
|
+
host = host.slice(0, zone);
|
|
43
|
+
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
44
|
+
if (v4) {
|
|
45
|
+
const o = v4.slice(1).map(Number);
|
|
46
|
+
if (o.some((n) => n > 255))
|
|
47
|
+
return false;
|
|
48
|
+
return isPrivateV4(o[0], o[1]);
|
|
49
|
+
}
|
|
50
|
+
if (host.includes(":")) {
|
|
51
|
+
const mapped = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(host);
|
|
52
|
+
if (mapped) {
|
|
53
|
+
const o = mapped[1].split(".").map(Number);
|
|
54
|
+
if (!o.some((n) => n > 255) && isPrivateV4(o[0], o[1])) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (host === "::1" || host === "::")
|
|
59
|
+
return true;
|
|
60
|
+
if (host === "::ffff:0:0")
|
|
61
|
+
return true;
|
|
62
|
+
if (/^f[cd]/.test(host))
|
|
63
|
+
return true;
|
|
64
|
+
if (/^fe[89ab]/.test(host))
|
|
65
|
+
return true;
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
function isPrivateV4(a, b) {
|
|
71
|
+
if (a === 127)
|
|
72
|
+
return true;
|
|
73
|
+
if (a === 10)
|
|
74
|
+
return true;
|
|
75
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
76
|
+
return true;
|
|
77
|
+
if (a === 192 && b === 168)
|
|
78
|
+
return true;
|
|
79
|
+
if (a === 169 && b === 254)
|
|
80
|
+
return true;
|
|
81
|
+
if (a === 0)
|
|
82
|
+
return true;
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
function assertSafeFetchUrl(url, opts = {}) {
|
|
86
|
+
let u;
|
|
87
|
+
try {
|
|
88
|
+
u = new URL(url);
|
|
89
|
+
} catch {
|
|
90
|
+
throw new Error(`Invalid Hubfluencer URL: ${url}`);
|
|
91
|
+
}
|
|
92
|
+
const host = u.hostname;
|
|
93
|
+
const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
94
|
+
if (u.protocol === "https:") {
|
|
95
|
+
if (isPrivateOrMetadataHost(host)) {
|
|
96
|
+
throw new Error(`Refusing to use a Hubfluencer URL pointing at a private/internal host (${host}). ` + "Use the public https endpoint.");
|
|
97
|
+
}
|
|
98
|
+
return u;
|
|
99
|
+
}
|
|
100
|
+
if (opts.allowLoopback && isLoopback)
|
|
101
|
+
return u;
|
|
102
|
+
throw new Error(`Refusing to use an insecure Hubfluencer URL (${u.protocol}//${host}). ` + "Use an https URL (or localhost for local development).");
|
|
103
|
+
}
|
|
104
|
+
function asRecord(v) {
|
|
105
|
+
return v && typeof v === "object" ? v : {};
|
|
106
|
+
}
|
|
107
|
+
function normalizeStatus(kind, slug, data) {
|
|
108
|
+
const d = asRecord(data);
|
|
109
|
+
const latest = asRecord(d.latest_render);
|
|
110
|
+
const videoUrl = latest.video_url ?? null;
|
|
111
|
+
if (kind === "short") {
|
|
112
|
+
const stage = d.stage ?? "unknown";
|
|
113
|
+
return {
|
|
114
|
+
kind,
|
|
115
|
+
slug,
|
|
116
|
+
stage,
|
|
117
|
+
terminal: stage === "video_ready" || stage === "failed",
|
|
118
|
+
ready: stage === "video_ready",
|
|
119
|
+
video_url: videoUrl,
|
|
120
|
+
error: d.error_message ?? null
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const autopilot = d.autopilot_status ?? "unknown";
|
|
124
|
+
const renderStatus = latest.status ?? null;
|
|
125
|
+
const ready = renderStatus === "completed" && Boolean(videoUrl);
|
|
126
|
+
const terminal = ready || autopilot === "failed" || autopilot === "cancelled" || renderStatus === "failed";
|
|
127
|
+
return {
|
|
128
|
+
kind,
|
|
129
|
+
slug,
|
|
130
|
+
stage: autopilot,
|
|
131
|
+
terminal,
|
|
132
|
+
ready,
|
|
133
|
+
video_url: videoUrl,
|
|
134
|
+
error: d.autopilot_error_message ?? null
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function idemKey(...parts) {
|
|
138
|
+
return createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 32);
|
|
139
|
+
}
|
|
140
|
+
function resolveSavePath(savePath) {
|
|
141
|
+
const base = resolve(process.env.HUBFLUENCER_OUTPUT_DIR || process.cwd());
|
|
142
|
+
const target = isAbsolute(savePath) ? resolve(savePath) : resolve(base, savePath);
|
|
143
|
+
if (!target.toLowerCase().endsWith(".mp4")) {
|
|
144
|
+
throw new Error("save_path must end in .mp4");
|
|
145
|
+
}
|
|
146
|
+
if (target !== base && !target.startsWith(base + sep)) {
|
|
147
|
+
throw new Error(`save_path must be inside ${base} (set HUBFLUENCER_OUTPUT_DIR to change). Refusing to write to ${target}.`);
|
|
148
|
+
}
|
|
149
|
+
return target;
|
|
150
|
+
}
|
|
151
|
+
function inferKind(prompt) {
|
|
152
|
+
const p = prompt.toLowerCase();
|
|
153
|
+
const wantsShort = /\b(short|quick|simple|snappy|single[- ]?clip|one[- ]?clip|teaser)\b/.test(p) || /(rapide|simple|court|clip unique|tease?r)/.test(p);
|
|
154
|
+
if (wantsShort)
|
|
155
|
+
return "short";
|
|
156
|
+
const wantsEditor = /\b(ads?|advert|advertisement|commercial|promo|campaign|launch|brand|story|stories|multi[- ]?scene|scenes?|narrat|explainer|chapters?|episodes?|testimonial|showcase|walkthrough|demo|spot)\b/.test(p) || /(pub(licit[ée])?|annonce|campagne|histoire|multi[- ]?sc[eè]ne|sc[eè]nes?|raconte|chapitres?|[eé]pisodes?|explicat|lancement|t[eé]moignage|d[eé]mo|vitrine)/.test(p);
|
|
157
|
+
return wantsEditor ? "editor" : "short";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/credentials.ts
|
|
161
|
+
import { readFileSync, statSync } from "node:fs";
|
|
162
|
+
import { homedir } from "node:os";
|
|
163
|
+
import { join } from "node:path";
|
|
164
|
+
var CREDENTIALS_PATH = process.env.HUBFLUENCER_CREDENTIALS || join(homedir(), ".hubfluencer", "credentials.json");
|
|
165
|
+
function readStoredCredentials() {
|
|
166
|
+
let raw;
|
|
167
|
+
try {
|
|
168
|
+
raw = readFileSync(CREDENTIALS_PATH, "utf8");
|
|
169
|
+
} catch {
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
if (process.platform !== "win32") {
|
|
173
|
+
try {
|
|
174
|
+
const mode = statSync(CREDENTIALS_PATH).mode;
|
|
175
|
+
if (mode & 63) {
|
|
176
|
+
console.error(`Warning: ${CREDENTIALS_PATH} is accessible to other users ` + `(mode ${(mode & 511).toString(8)}). Run: chmod 600 ${CREDENTIALS_PATH}`);
|
|
177
|
+
}
|
|
178
|
+
} catch {}
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(raw);
|
|
182
|
+
} catch {
|
|
183
|
+
return {};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/client.ts
|
|
188
|
+
function makeError(status, body) {
|
|
189
|
+
let message = `Hubfluencer API error (HTTP ${status})`;
|
|
190
|
+
let code;
|
|
191
|
+
if (body && typeof body === "object") {
|
|
192
|
+
const b = body;
|
|
193
|
+
const errVal = b.error;
|
|
194
|
+
if (typeof errVal === "string") {
|
|
195
|
+
if (/^[a-z][a-z0-9_]*$/.test(errVal)) {
|
|
196
|
+
code = errVal;
|
|
197
|
+
message = b.message || errVal;
|
|
198
|
+
} else {
|
|
199
|
+
message = b.message || errVal;
|
|
200
|
+
}
|
|
201
|
+
} else if (errVal && typeof errVal === "object") {
|
|
202
|
+
const e = errVal;
|
|
203
|
+
code = e.code || undefined;
|
|
204
|
+
message = e.message || message;
|
|
205
|
+
} else if (typeof b.message === "string") {
|
|
206
|
+
message = b.message;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const err = new Error(message);
|
|
210
|
+
err.status = status;
|
|
211
|
+
err.code = code;
|
|
212
|
+
err.body = body;
|
|
213
|
+
return err;
|
|
214
|
+
}
|
|
215
|
+
function assertSafeBaseUrl(baseUrl) {
|
|
216
|
+
assertSafeFetchUrl(baseUrl, { allowLoopback: true });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
class HubfluencerClient {
|
|
220
|
+
baseUrl;
|
|
221
|
+
token;
|
|
222
|
+
constructor(baseUrl, token) {
|
|
223
|
+
this.baseUrl = baseUrl;
|
|
224
|
+
this.token = token;
|
|
225
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
|
|
226
|
+
assertSafeBaseUrl(this.baseUrl);
|
|
227
|
+
}
|
|
228
|
+
async request(method, path, opts = {}) {
|
|
229
|
+
const url = new URL(`${this.baseUrl}/api${path}`);
|
|
230
|
+
if (opts.query) {
|
|
231
|
+
for (const [k, v] of Object.entries(opts.query)) {
|
|
232
|
+
if (v !== undefined)
|
|
233
|
+
url.searchParams.set(k, String(v));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const headers = {
|
|
237
|
+
authorization: `Bearer ${this.token}`,
|
|
238
|
+
accept: "application/json"
|
|
239
|
+
};
|
|
240
|
+
if (opts.body !== undefined)
|
|
241
|
+
headers["content-type"] = "application/json";
|
|
242
|
+
if (opts.idempotencyKey)
|
|
243
|
+
headers["idempotency-key"] = opts.idempotencyKey;
|
|
244
|
+
let res;
|
|
245
|
+
try {
|
|
246
|
+
res = await fetch(url, {
|
|
247
|
+
method,
|
|
248
|
+
headers,
|
|
249
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
250
|
+
signal: AbortSignal.timeout(60000)
|
|
251
|
+
});
|
|
252
|
+
} catch (e) {
|
|
253
|
+
if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
|
|
254
|
+
throw new Error(`Request to ${method} ${path} timed out after 60s.`);
|
|
255
|
+
}
|
|
256
|
+
throw e instanceof Error ? new Error(`Network error on ${method} ${path}: ${e.message}`) : e;
|
|
257
|
+
}
|
|
258
|
+
if (res.status === 204)
|
|
259
|
+
return;
|
|
260
|
+
const text = await res.text();
|
|
261
|
+
let parsed;
|
|
262
|
+
if (text) {
|
|
263
|
+
try {
|
|
264
|
+
parsed = JSON.parse(text);
|
|
265
|
+
} catch {
|
|
266
|
+
parsed = text;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (!res.ok)
|
|
270
|
+
throw makeError(res.status, parsed);
|
|
271
|
+
return parsed;
|
|
272
|
+
}
|
|
273
|
+
get(path, query) {
|
|
274
|
+
return this.request("GET", path, { query });
|
|
275
|
+
}
|
|
276
|
+
post(path, body, idempotencyKey) {
|
|
277
|
+
return this.request("POST", path, { body, idempotencyKey });
|
|
278
|
+
}
|
|
279
|
+
patch(path, body, idempotencyKey) {
|
|
280
|
+
return this.request("PATCH", path, { body, idempotencyKey });
|
|
281
|
+
}
|
|
282
|
+
put(path, body, idempotencyKey) {
|
|
283
|
+
return this.request("PUT", path, { body, idempotencyKey });
|
|
284
|
+
}
|
|
285
|
+
del(path) {
|
|
286
|
+
return this.request("DELETE", path);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function clientFromEnv() {
|
|
290
|
+
const stored = readStoredCredentials();
|
|
291
|
+
const token = process.env.HUBFLUENCER_API_TOKEN || stored.token;
|
|
292
|
+
const baseUrl = process.env.HUBFLUENCER_BASE_URL || stored.base_url || "https://hubfluencer.com";
|
|
293
|
+
if (!token) {
|
|
294
|
+
throw new Error("Not connected to Hubfluencer. Run `hubfluencer-login` to connect, or set " + "HUBFLUENCER_API_TOKEN (create one in the app: Settings → Access tokens).");
|
|
295
|
+
}
|
|
296
|
+
return new HubfluencerClient(baseUrl, token);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/login.ts
|
|
300
|
+
var BASE = (process.env.HUBFLUENCER_BASE_URL || "https://hubfluencer.com").replace(/\/+$/, "").replace(/\/api$/, "");
|
|
301
|
+
assertSafeBaseUrl(BASE);
|
|
302
|
+
var MAX_POLLS = 120;
|
|
303
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
304
|
+
async function main() {
|
|
305
|
+
const clientName = process.argv[2] || "Claude Code";
|
|
306
|
+
const startRes = await fetch(`${BASE}/api/agent-link/start`, {
|
|
307
|
+
method: "POST",
|
|
308
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
309
|
+
body: JSON.stringify({
|
|
310
|
+
client_name: clientName,
|
|
311
|
+
scopes: ["video:generate", "video:read"]
|
|
312
|
+
})
|
|
313
|
+
});
|
|
314
|
+
if (!startRes.ok) {
|
|
315
|
+
console.error(`Could not start login (HTTP ${startRes.status}). Check HUBFLUENCER_BASE_URL.`);
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
const start = await startRes.json();
|
|
319
|
+
const url = start.verification_uri_complete || start.verification_uri;
|
|
320
|
+
console.error(`
|
|
321
|
+
Connect this agent to Hubfluencer:
|
|
322
|
+
`);
|
|
323
|
+
console.error(` 1. Open: ${url}`);
|
|
324
|
+
console.error(` 2. Confirm code: ${start.user_code}`);
|
|
325
|
+
console.error(` 3. Click Approve (you'll need to be signed in).
|
|
326
|
+
`);
|
|
327
|
+
console.error("Waiting for approval…");
|
|
328
|
+
let intervalMs = Math.max(2, start.interval ?? 5) * 1000;
|
|
329
|
+
const MAX_INTERVAL_MS = 30000;
|
|
330
|
+
for (let i = 0;i < MAX_POLLS; i++) {
|
|
331
|
+
await sleep(intervalMs);
|
|
332
|
+
const pollRes = await fetch(`${BASE}/api/agent-link/poll`, {
|
|
333
|
+
method: "POST",
|
|
334
|
+
headers: {
|
|
335
|
+
"content-type": "application/json",
|
|
336
|
+
accept: "application/json"
|
|
337
|
+
},
|
|
338
|
+
body: JSON.stringify({ device_code: start.device_code })
|
|
339
|
+
});
|
|
340
|
+
const body = await pollRes.json().catch(() => ({}));
|
|
341
|
+
if (pollRes.ok && body.status === "approved" && body.token) {
|
|
342
|
+
await storeToken(body.token);
|
|
343
|
+
console.error(`
|
|
344
|
+
✓ Connected. Access token saved to ${CREDENTIALS_PATH}.`);
|
|
345
|
+
console.error(` Revoke anytime in the app: Settings → Access tokens.
|
|
346
|
+
`);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (body.status === "slow_down") {
|
|
350
|
+
intervalMs = Math.min(intervalMs + 5000, MAX_INTERVAL_MS);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (body.status === "pending")
|
|
354
|
+
continue;
|
|
355
|
+
console.error(`
|
|
356
|
+
✗ ${body.error || body.status || `HTTP ${pollRes.status}`}. Run login again.`);
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
console.error(`
|
|
360
|
+
✗ Timed out waiting for approval. Run login again.`);
|
|
361
|
+
process.exit(1);
|
|
362
|
+
}
|
|
363
|
+
async function storeToken(token) {
|
|
364
|
+
const dir = dirname(CREDENTIALS_PATH);
|
|
365
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
366
|
+
await writeFile(CREDENTIALS_PATH, JSON.stringify({ token, base_url: BASE }, null, 2), {
|
|
367
|
+
mode: 384
|
|
368
|
+
});
|
|
369
|
+
try {
|
|
370
|
+
await chmod(CREDENTIALS_PATH, 384);
|
|
371
|
+
await chmod(dir, 448);
|
|
372
|
+
} catch {}
|
|
373
|
+
}
|
|
374
|
+
main().catch((e) => {
|
|
375
|
+
console.error("Fatal:", e instanceof Error ? e.message : String(e));
|
|
376
|
+
process.exit(1);
|
|
377
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hubfluencer/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Monocursive <contact@monocursive.com>",
|
|
7
|
+
"private": false,
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"hubfluencer-mcp": "./dist/index.js",
|
|
11
|
+
"hubfluencer-login": "./dist/login.js"
|
|
12
|
+
},
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src",
|
|
17
|
+
"tsconfig.json",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"dev": "bun run src/index.ts",
|
|
23
|
+
"build": "rm -rf dist && bun build src/index.ts src/login.ts --target node --outdir dist",
|
|
24
|
+
"prepublishOnly": "bun run build",
|
|
25
|
+
"start": "node dist/index.js",
|
|
26
|
+
"test": "bun test",
|
|
27
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
28
|
+
"lint": "biome check src",
|
|
29
|
+
"release": "bun run typecheck && bun run lint && bun test && npm publish --access public"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
33
|
+
"zod": "^3.23.8"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^22.10.0",
|
|
37
|
+
"typescript": "^5.9.3"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"mcp",
|
|
44
|
+
"model-context-protocol",
|
|
45
|
+
"hubfluencer",
|
|
46
|
+
"ai",
|
|
47
|
+
"video",
|
|
48
|
+
"ads",
|
|
49
|
+
"claude"
|
|
50
|
+
]
|
|
51
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin REST client for the Hubfluencer API.
|
|
3
|
+
*
|
|
4
|
+
* Auth: forwards an opaque bearer token (a Personal Access Token from
|
|
5
|
+
* `POST /api/tokens`, or a session token from `POST /api/sign-in`) verbatim
|
|
6
|
+
* as `Authorization: Bearer <token>`. The token is NOT a JWT — never decode it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { assertSafeFetchUrl } from "./core.js";
|
|
10
|
+
import { readStoredCredentials } from "./credentials.js";
|
|
11
|
+
|
|
12
|
+
export interface HubfluencerError extends Error {
|
|
13
|
+
status: number;
|
|
14
|
+
code?: string;
|
|
15
|
+
body?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeError(status: number, body: unknown): HubfluencerError {
|
|
19
|
+
// The API's error envelope is not fully uniform; pull a message/code out of
|
|
20
|
+
// whatever shape we got.
|
|
21
|
+
let message = `Hubfluencer API error (HTTP ${status})`;
|
|
22
|
+
let code: string | undefined;
|
|
23
|
+
|
|
24
|
+
if (body && typeof body === "object") {
|
|
25
|
+
const b = body as Record<string, unknown>;
|
|
26
|
+
const errVal = b.error;
|
|
27
|
+
if (typeof errVal === "string") {
|
|
28
|
+
// `error` carries two different things across the API: a machine-stable
|
|
29
|
+
// snake_case code an agent can branch on (e.g. "credits_insufficient",
|
|
30
|
+
// "editor_voice_stale", "batch_generation_active"), OR — in a handful of
|
|
31
|
+
// older clauses — a full English sentence. Only treat it as a `code` when
|
|
32
|
+
// it actually looks like a token; otherwise it's the human message. Without
|
|
33
|
+
// this guard a sentence becomes a bogus `code` and is rendered as the
|
|
34
|
+
// message twice, and downstream tools can't reliably switch on `err.code`.
|
|
35
|
+
if (/^[a-z][a-z0-9_]*$/.test(errVal)) {
|
|
36
|
+
code = errVal;
|
|
37
|
+
message = (b.message as string) || errVal;
|
|
38
|
+
} else {
|
|
39
|
+
message = (b.message as string) || errVal;
|
|
40
|
+
}
|
|
41
|
+
} else if (errVal && typeof errVal === "object") {
|
|
42
|
+
const e = errVal as Record<string, unknown>;
|
|
43
|
+
code = (e.code as string) || undefined;
|
|
44
|
+
message = (e.message as string) || message;
|
|
45
|
+
} else if (typeof b.message === "string") {
|
|
46
|
+
message = b.message;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const err = new Error(message) as HubfluencerError;
|
|
51
|
+
err.status = status;
|
|
52
|
+
err.code = code;
|
|
53
|
+
err.body = body;
|
|
54
|
+
return err;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Refuses to send the bearer token over an insecure, malformed, or
|
|
59
|
+
* internal-pointing base URL. The token is a long-lived credential; an
|
|
60
|
+
* `http://` host (or a tampered `base_url` in credentials.json /
|
|
61
|
+
* HUBFLUENCER_BASE_URL pointing somewhere unexpected) would leak it in
|
|
62
|
+
* cleartext or to the wrong origin. Delegates to the shared `assertSafeFetchUrl`
|
|
63
|
+
* guard: https required (loopback http allowed for local dev), and any
|
|
64
|
+
* private/link-local/metadata IP literal refused even over https so the token
|
|
65
|
+
* can't be redirected to an internal host (e.g. 169.254.169.254, 10.x, ::1).
|
|
66
|
+
*/
|
|
67
|
+
export function assertSafeBaseUrl(baseUrl: string): void {
|
|
68
|
+
assertSafeFetchUrl(baseUrl, { allowLoopback: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface RequestOptions {
|
|
72
|
+
body?: unknown;
|
|
73
|
+
/** Sent as the `Idempotency-Key` header on chargeable POSTs. */
|
|
74
|
+
idempotencyKey?: string;
|
|
75
|
+
query?: Record<string, string | number | undefined>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class HubfluencerClient {
|
|
79
|
+
constructor(
|
|
80
|
+
private readonly baseUrl: string,
|
|
81
|
+
private readonly token: string,
|
|
82
|
+
) {
|
|
83
|
+
// Normalize to the bare origin: the REST API lives under `/api`, which
|
|
84
|
+
// request() always prepends. Tolerate a base that already ends in `/api`
|
|
85
|
+
// (older docs/env values) so we never produce a `/api/api/...` URL.
|
|
86
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
|
|
87
|
+
// Never forward the bearer token over an insecure/unexpected origin.
|
|
88
|
+
assertSafeBaseUrl(this.baseUrl);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async request<T = unknown>(
|
|
92
|
+
method: string,
|
|
93
|
+
path: string,
|
|
94
|
+
opts: RequestOptions = {},
|
|
95
|
+
): Promise<T> {
|
|
96
|
+
// Every tool passes a bare API path (e.g. "/editor/:slug"); the API is
|
|
97
|
+
// mounted under "/api" on the app host (https://hubfluencer.com), so add
|
|
98
|
+
// it here in one place rather than in every call site.
|
|
99
|
+
const url = new URL(`${this.baseUrl}/api${path}`);
|
|
100
|
+
if (opts.query) {
|
|
101
|
+
for (const [k, v] of Object.entries(opts.query)) {
|
|
102
|
+
if (v !== undefined) url.searchParams.set(k, String(v));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const headers: Record<string, string> = {
|
|
107
|
+
authorization: `Bearer ${this.token}`,
|
|
108
|
+
accept: "application/json",
|
|
109
|
+
};
|
|
110
|
+
if (opts.body !== undefined) headers["content-type"] = "application/json";
|
|
111
|
+
if (opts.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey;
|
|
112
|
+
|
|
113
|
+
// Bound every API call so a hung connection fails the tool cleanly
|
|
114
|
+
// instead of blocking the agent indefinitely (fetch has no default
|
|
115
|
+
// timeout). All endpoints return quickly — generation runs async server
|
|
116
|
+
// side, so 60s is generous headroom.
|
|
117
|
+
let res: Response;
|
|
118
|
+
try {
|
|
119
|
+
res = await fetch(url, {
|
|
120
|
+
method,
|
|
121
|
+
headers,
|
|
122
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
123
|
+
signal: AbortSignal.timeout(60_000),
|
|
124
|
+
});
|
|
125
|
+
} catch (e) {
|
|
126
|
+
if (
|
|
127
|
+
e instanceof Error &&
|
|
128
|
+
(e.name === "TimeoutError" || e.name === "AbortError")
|
|
129
|
+
) {
|
|
130
|
+
throw new Error(`Request to ${method} ${path} timed out after 60s.`);
|
|
131
|
+
}
|
|
132
|
+
throw e instanceof Error
|
|
133
|
+
? new Error(`Network error on ${method} ${path}: ${e.message}`)
|
|
134
|
+
: e;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (res.status === 204) return undefined as T;
|
|
138
|
+
|
|
139
|
+
const text = await res.text();
|
|
140
|
+
let parsed: unknown;
|
|
141
|
+
if (text) {
|
|
142
|
+
try {
|
|
143
|
+
parsed = JSON.parse(text);
|
|
144
|
+
} catch {
|
|
145
|
+
parsed = text;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!res.ok) throw makeError(res.status, parsed);
|
|
150
|
+
return parsed as T;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
get<T = unknown>(path: string, query?: RequestOptions["query"]): Promise<T> {
|
|
154
|
+
return this.request<T>("GET", path, { query });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
post<T = unknown>(
|
|
158
|
+
path: string,
|
|
159
|
+
body?: unknown,
|
|
160
|
+
idempotencyKey?: string,
|
|
161
|
+
): Promise<T> {
|
|
162
|
+
return this.request<T>("POST", path, { body, idempotencyKey });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
patch<T = unknown>(
|
|
166
|
+
path: string,
|
|
167
|
+
body?: unknown,
|
|
168
|
+
idempotencyKey?: string,
|
|
169
|
+
): Promise<T> {
|
|
170
|
+
return this.request<T>("PATCH", path, { body, idempotencyKey });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
put<T = unknown>(
|
|
174
|
+
path: string,
|
|
175
|
+
body?: unknown,
|
|
176
|
+
idempotencyKey?: string,
|
|
177
|
+
): Promise<T> {
|
|
178
|
+
return this.request<T>("PUT", path, { body, idempotencyKey });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
del<T = unknown>(path: string): Promise<T> {
|
|
182
|
+
return this.request<T>("DELETE", path);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Builds a client from the environment, falling back to the device-link
|
|
188
|
+
* credential file (written by `hubfluencer-login`). Throws a clear, actionable
|
|
189
|
+
* error if no token is available.
|
|
190
|
+
*/
|
|
191
|
+
export function clientFromEnv(): HubfluencerClient {
|
|
192
|
+
const stored = readStoredCredentials();
|
|
193
|
+
const token = process.env.HUBFLUENCER_API_TOKEN || stored.token;
|
|
194
|
+
const baseUrl =
|
|
195
|
+
process.env.HUBFLUENCER_BASE_URL ||
|
|
196
|
+
stored.base_url ||
|
|
197
|
+
"https://hubfluencer.com";
|
|
198
|
+
|
|
199
|
+
if (!token) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
"Not connected to Hubfluencer. Run `hubfluencer-login` to connect, or set " +
|
|
202
|
+
"HUBFLUENCER_API_TOKEN (create one in the app: Settings → Access tokens).",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return new HubfluencerClient(baseUrl, token);
|
|
206
|
+
}
|