@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/src/core.ts ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Pure, side-effect-free helpers shared by the server (index.ts) and its tests.
3
+ *
4
+ * These live outside index.ts on purpose: index.ts registers 23+ tools and
5
+ * connects an stdio transport at module load, so importing it from a unit test
6
+ * would boot a live MCP server. Everything here is dependency-light (crypto +
7
+ * path only) and safe to import in isolation.
8
+ */
9
+
10
+ import { createHash } from "node:crypto";
11
+ import { isAbsolute, resolve, sep } from "node:path";
12
+
13
+ export type Kind = "short" | "editor";
14
+
15
+ /**
16
+ * True if `hostname` is an IP literal in a private, loopback, link-local, or
17
+ * cloud-metadata range — i.e. somewhere we must never send a credential or
18
+ * exfiltrate bytes to, even over https. A non-IP hostname (a real DNS name) is
19
+ * treated as public/allowed: we can't resolve it here without a DNS lookup, and
20
+ * the threat model is a tampered base_url / API response pointing at an internal
21
+ * *literal* (e.g. 169.254.169.254, 10.x, ::1). Handles IPv4 dotted-quad, IPv6
22
+ * (incl. bracketed and zone-id forms), and IPv4-mapped IPv6.
23
+ */
24
+ export function isPrivateOrMetadataHost(hostname: string): boolean {
25
+ // Strip IPv6 brackets and any zone id (e.g. "[fe80::1%eth0]" → "fe80::1").
26
+ let host = hostname.trim().toLowerCase();
27
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
28
+ const zone = host.indexOf("%");
29
+ if (zone !== -1) host = host.slice(0, zone);
30
+
31
+ // IPv4 dotted-quad.
32
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
33
+ if (v4) {
34
+ const o = v4.slice(1).map(Number);
35
+ if (o.some((n) => n > 255)) return false; // not a valid IPv4 literal
36
+ return isPrivateV4(o[0], o[1]);
37
+ }
38
+
39
+ // IPv6 (anything with a colon). Normalize away an IPv4-mapped suffix first.
40
+ if (host.includes(":")) {
41
+ // ::ffff:a.b.c.d / ::a.b.c.d — judge by the embedded IPv4.
42
+ const mapped = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(host);
43
+ if (mapped) {
44
+ const o = mapped[1].split(".").map(Number);
45
+ if (!o.some((n) => n > 255) && isPrivateV4(o[0], o[1])) {
46
+ return true;
47
+ }
48
+ }
49
+ if (host === "::1" || host === "::") return true; // loopback / unspecified
50
+ if (host === "::ffff:0:0") return true;
51
+ // fc00::/7 (unique-local) and fe80::/10 (link-local).
52
+ if (/^f[cd]/.test(host)) return true;
53
+ if (/^fe[89ab]/.test(host)) return true;
54
+ return false;
55
+ }
56
+
57
+ return false;
58
+ }
59
+
60
+ // Classifies an IPv4 address by its first two octets — all the blocked ranges
61
+ // (loopback, RFC-1918 private, link-local/metadata, unspecified) are determined
62
+ // by a/b alone, so c/d aren't needed.
63
+ function isPrivateV4(a: number, b: number): boolean {
64
+ if (a === 127) return true; // 127.0.0.0/8 loopback
65
+ if (a === 10) return true; // 10.0.0.0/8
66
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
67
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
68
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + metadata
69
+ if (a === 0) return true; // 0.0.0.0/8 (incl. 0.0.0.0)
70
+ return false;
71
+ }
72
+
73
+ /**
74
+ * Guards a URL we're about to send a credential to, or fetch/PUT bytes against:
75
+ * the base API URL (assertSafeBaseUrl) AND every server-returned presigned /
76
+ * download URL (uploads.ts / index.ts). Requires https — refusing an http
77
+ * downgrade that would leak the token in cleartext or move file bytes in the
78
+ * clear — and refuses any private/link-local/metadata IP literal so a tampered
79
+ * base_url or a compromised/MITM'd API response can't redirect the credential
80
+ * or the bytes to an internal host (SSRF). Loopback http is allowed ONLY when
81
+ * `allowLoopback` is set (local development). NEVER include a token in the
82
+ * thrown message.
83
+ */
84
+ export function assertSafeFetchUrl(
85
+ url: string,
86
+ opts: { allowLoopback?: boolean } = {},
87
+ ): URL {
88
+ let u: URL;
89
+ try {
90
+ u = new URL(url);
91
+ } catch {
92
+ throw new Error(`Invalid Hubfluencer URL: ${url}`);
93
+ }
94
+ const host = u.hostname;
95
+ const isLoopback =
96
+ host === "localhost" ||
97
+ host === "127.0.0.1" ||
98
+ host === "::1" ||
99
+ host === "[::1]";
100
+
101
+ if (u.protocol === "https:") {
102
+ // https is necessary but not sufficient — a private/metadata IP literal
103
+ // over https is still an internal-redirect we refuse.
104
+ if (isPrivateOrMetadataHost(host)) {
105
+ throw new Error(
106
+ `Refusing to use a Hubfluencer URL pointing at a private/internal host (${host}). ` +
107
+ "Use the public https endpoint.",
108
+ );
109
+ }
110
+ return u;
111
+ }
112
+
113
+ // Non-https: only loopback, only when explicitly allowed (local dev).
114
+ if (opts.allowLoopback && isLoopback) return u;
115
+
116
+ throw new Error(
117
+ `Refusing to use an insecure Hubfluencer URL (${u.protocol}//${host}). ` +
118
+ "Use an https URL (or localhost for local development).",
119
+ );
120
+ }
121
+
122
+ export interface NormalizedStatus {
123
+ kind: Kind;
124
+ slug: string;
125
+ stage: string;
126
+ terminal: boolean;
127
+ ready: boolean;
128
+ video_url: string | null;
129
+ error: string | null;
130
+ }
131
+
132
+ export function asRecord(v: unknown): Record<string, unknown> {
133
+ return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
134
+ }
135
+
136
+ /** Collapses the short/editor state payloads into one shape the agent can poll. */
137
+ export function normalizeStatus(
138
+ kind: Kind,
139
+ slug: string,
140
+ data: unknown,
141
+ ): NormalizedStatus {
142
+ const d = asRecord(data);
143
+ const latest = asRecord(d.latest_render);
144
+ const videoUrl = (latest.video_url as string) ?? null;
145
+
146
+ if (kind === "short") {
147
+ const stage = (d.stage as string) ?? "unknown";
148
+ return {
149
+ kind,
150
+ slug,
151
+ stage,
152
+ terminal: stage === "video_ready" || stage === "failed",
153
+ ready: stage === "video_ready",
154
+ video_url: videoUrl,
155
+ error: (d.error_message as string) ?? null,
156
+ };
157
+ }
158
+
159
+ // editor
160
+ const autopilot = (d.autopilot_status as string) ?? "unknown";
161
+ const renderStatus = (latest.status as string) ?? null;
162
+ const ready = renderStatus === "completed" && Boolean(videoUrl);
163
+ const terminal =
164
+ ready ||
165
+ autopilot === "failed" ||
166
+ autopilot === "cancelled" ||
167
+ renderStatus === "failed";
168
+ return {
169
+ kind,
170
+ slug,
171
+ stage: autopilot,
172
+ terminal,
173
+ ready,
174
+ video_url: videoUrl,
175
+ error: (d.autopilot_error_message as string) ?? null,
176
+ };
177
+ }
178
+
179
+ /** Stable idempotency key for a logical operation, so a retried call is safe. */
180
+ export function idemKey(...parts: string[]): string {
181
+ return createHash("sha256")
182
+ .update(parts.join("|"))
183
+ .digest("hex")
184
+ .slice(0, 32);
185
+ }
186
+
187
+ /**
188
+ * Confines a model-supplied save_path to an allowlisted output base so a
189
+ * prompt-injected path can't write outside it (e.g. ~/.ssh/authorized_keys).
190
+ * Base = HUBFLUENCER_OUTPUT_DIR if set, else the current working directory.
191
+ * Rejects traversal and any non-.mp4 target.
192
+ */
193
+ export function resolveSavePath(savePath: string): string {
194
+ const base = resolve(process.env.HUBFLUENCER_OUTPUT_DIR || process.cwd());
195
+ const target = isAbsolute(savePath)
196
+ ? resolve(savePath)
197
+ : resolve(base, savePath);
198
+ if (!target.toLowerCase().endsWith(".mp4")) {
199
+ throw new Error("save_path must end in .mp4");
200
+ }
201
+ if (target !== base && !target.startsWith(base + sep)) {
202
+ throw new Error(
203
+ `save_path must be inside ${base} (set HUBFLUENCER_OUTPUT_DIR to change). Refusing to write to ${target}.`,
204
+ );
205
+ }
206
+ return target;
207
+ }
208
+
209
+ /**
210
+ * Picks short vs editor for make_video's kind:"auto".
211
+ *
212
+ * Bias: an ad/commercial/promo/story brief → the multi-scene EDITOR ad (what
213
+ * someone asking for "a full ad" pictures), UNLESS the prompt explicitly asks
214
+ * for something brief/simple → the cheaper single-clip SHORT. A prompt with no
215
+ * signal at all falls back to the cheaper short. The product is bilingual
216
+ * (EN/FR); accented chars don't sit on \b cleanly, so the FR patterns avoid
217
+ * word boundaries.
218
+ *
219
+ * Whatever this returns, make_video surfaces it (kind_inferred) plus the priced
220
+ * cost before charging, so a wrong guess is visible and overridable — never a
221
+ * silent expensive surprise.
222
+ */
223
+ export function inferKind(prompt: string): Kind {
224
+ const p = prompt.toLowerCase();
225
+
226
+ // Explicit "keep it short/simple" intent wins — route to the cheap short. A
227
+ // bare duration ("a 30 second ad") is deliberately NOT a brevity signal: many
228
+ // ad briefs name a length, and it shouldn't override clear ad/story intent.
229
+ const wantsShort =
230
+ /\b(short|quick|simple|snappy|single[- ]?clip|one[- ]?clip|teaser)\b/.test(
231
+ p,
232
+ ) || /(rapide|simple|court|clip unique|tease?r)/.test(p);
233
+ if (wantsShort) return "short";
234
+
235
+ // Ad / marketing / multi-scene / story intent → the full multi-scene editor.
236
+ const wantsEditor =
237
+ /\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(
238
+ p,
239
+ ) ||
240
+ /(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(
241
+ p,
242
+ );
243
+ return wantsEditor ? "editor" : "short";
244
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Local credential store for the device-link flow. The CLI writes the scoped
3
+ * access token here (mode 0600); the MCP server reads it as a fallback when
4
+ * HUBFLUENCER_API_TOKEN isn't set in the environment.
5
+ */
6
+
7
+ import { readFileSync, statSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ export const CREDENTIALS_PATH =
12
+ process.env.HUBFLUENCER_CREDENTIALS ||
13
+ join(homedir(), ".hubfluencer", "credentials.json");
14
+
15
+ export interface StoredCredentials {
16
+ token?: string;
17
+ base_url?: string;
18
+ }
19
+
20
+ export function readStoredCredentials(): StoredCredentials {
21
+ let raw: string;
22
+ try {
23
+ raw = readFileSync(CREDENTIALS_PATH, "utf8");
24
+ } catch {
25
+ return {};
26
+ }
27
+ // Best-effort warning if the token file is group/world-accessible — it holds a
28
+ // long-lived account credential. POSIX only; Windows reports mode oddly so we
29
+ // skip the check there. Never blocks the read.
30
+ if (process.platform !== "win32") {
31
+ try {
32
+ const mode = statSync(CREDENTIALS_PATH).mode;
33
+ if (mode & 0o077) {
34
+ console.error(
35
+ `Warning: ${CREDENTIALS_PATH} is accessible to other users ` +
36
+ `(mode ${(mode & 0o777).toString(8)}). Run: chmod 600 ${CREDENTIALS_PATH}`,
37
+ );
38
+ }
39
+ } catch {
40
+ // stat is advisory — never fail the read over it
41
+ }
42
+ }
43
+ try {
44
+ return JSON.parse(raw) as StoredCredentials;
45
+ } catch {
46
+ return {};
47
+ }
48
+ }