@hubfluencer/mcp 0.4.0 → 0.6.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 CHANGED
@@ -10,7 +10,7 @@
10
10
  import { createHash } from "node:crypto";
11
11
  import { isAbsolute, resolve, sep } from "node:path";
12
12
 
13
- export type Kind = "short" | "editor";
13
+ export type Kind = "short" | "editor" | "tracking" | "slider";
14
14
 
15
15
  /**
16
16
  * True if `hostname` is an IP literal in a private, loopback, link-local, or
@@ -29,32 +29,92 @@ export function isPrivateOrMetadataHost(hostname: string): boolean {
29
29
  if (zone !== -1) host = host.slice(0, zone);
30
30
 
31
31
  // IPv4 dotted-quad.
32
+ const v4 = parseV4Octets(host);
33
+ if (v4) return isPrivateV4(v4[0], v4[1]);
34
+
35
+ if (!host.includes(":")) return false;
36
+
37
+ const v6 = parseIPv6Words(host);
38
+ if (!v6) return false;
39
+
40
+ // IPv4 can be smuggled through several IPv6 literal forms:
41
+ // ::ffff:7f00:1 IPv4-mapped (hex words)
42
+ // ::7f00:1 deprecated IPv4-compatible
43
+ // 64:ff9b::a9fe:a9fe NAT64 well-known prefix
44
+ // Treat those by the embedded IPv4 range so private/metadata literals fail
45
+ // closed while public mapped addresses (e.g. ::ffff:8.8.8.8) still pass.
46
+ const embeddedV4 = embeddedIPv4Octets(v6);
47
+ if (embeddedV4 && isPrivateV4(embeddedV4[0], embeddedV4[1])) return true;
48
+
49
+ if (v6.every((word) => word === 0)) return true; // unspecified
50
+ if (v6.slice(0, 7).every((word) => word === 0) && v6[7] === 1) {
51
+ return true; // loopback
52
+ }
53
+ // fc00::/7 (unique-local) and fe80::/10 (link-local).
54
+ if ((v6[0] & 0xfe00) === 0xfc00) return true;
55
+ if ((v6[0] & 0xffc0) === 0xfe80) return true;
56
+ return false;
57
+ }
58
+
59
+ function parseV4Octets(host: string): number[] | undefined {
32
60
  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;
61
+ if (!v4) return undefined;
62
+ const octets = v4.slice(1).map(Number);
63
+ return octets.some((n) => n > 255) ? undefined : octets;
64
+ }
65
+
66
+ function parseIPv6Words(host: string): number[] | undefined {
67
+ if (host.includes(".")) {
68
+ const lastColon = host.lastIndexOf(":");
69
+ if (lastColon === -1) return undefined;
70
+ const v4 = parseV4Octets(host.slice(lastColon + 1));
71
+ if (!v4) return undefined;
72
+ host = `${host.slice(0, lastColon)}:${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}`;
55
73
  }
56
74
 
57
- return false;
75
+ const halves = host.split("::");
76
+ if (halves.length > 2) return undefined;
77
+
78
+ const head = splitIPv6Half(halves[0]);
79
+ const tail = halves.length === 2 ? splitIPv6Half(halves[1]) : [];
80
+ if (!head || !tail) return undefined;
81
+
82
+ if (halves.length === 1) return head.length === 8 ? head : undefined;
83
+
84
+ const missing = 8 - head.length - tail.length;
85
+ if (missing < 1) return undefined;
86
+ return [...head, ...Array(missing).fill(0), ...tail];
87
+ }
88
+
89
+ function splitIPv6Half(half: string): number[] | undefined {
90
+ if (half === "") return [];
91
+ const words = half.split(":");
92
+ const parsed: number[] = [];
93
+ for (const word of words) {
94
+ if (!/^[0-9a-f]{1,4}$/.test(word)) return undefined;
95
+ parsed.push(Number.parseInt(word, 16));
96
+ }
97
+ return parsed;
98
+ }
99
+
100
+ function embeddedIPv4Octets(v6: number[]): number[] | undefined {
101
+ const firstFiveZero = v6.slice(0, 5).every((word) => word === 0);
102
+ if (firstFiveZero && v6[5] === 0xffff) return wordsToV4(v6[6], v6[7]);
103
+
104
+ const firstSixZero = v6.slice(0, 6).every((word) => word === 0);
105
+ if (firstSixZero && (v6[6] !== 0 || v6[7] !== 0)) {
106
+ return wordsToV4(v6[6], v6[7]);
107
+ }
108
+
109
+ const isNat64WellKnown =
110
+ v6[0] === 0x64 &&
111
+ v6[1] === 0xff9b &&
112
+ v6.slice(2, 6).every((word) => word === 0);
113
+ return isNat64WellKnown ? wordsToV4(v6[6], v6[7]) : undefined;
114
+ }
115
+
116
+ function wordsToV4(hi: number, lo: number): number[] {
117
+ return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff];
58
118
  }
59
119
 
60
120
  // Classifies an IPv4 address by its first two octets — all the blocked ranges
@@ -119,6 +179,84 @@ export function assertSafeFetchUrl(
119
179
  );
120
180
  }
121
181
 
182
+ // ── Retry / bounded backoff (idempotent GETs only) ───────────────────────────
183
+ //
184
+ // One transient blip — a dropped connection, a 502 from a load balancer, a brief
185
+ // 429 — must not fail a whole tool call or, worse, an entire multi-minute poll
186
+ // loop (every poll iteration is a fresh GET). These pure helpers decide *whether*
187
+ // a failure is worth retrying and *how long* to wait; the actual loop lives in
188
+ // client.ts (request) and uploads.ts (multipart parts). They are the single
189
+ // source of truth for the policy, so it's unit-testable without the network.
190
+ //
191
+ // CRITICAL: retry is only ever applied to idempotent GETs (and the re-signed
192
+ // multipart PUT of a single part, whose semantics are also idempotent). A
193
+ // non-idempotent POST/PATCH/DELETE is NEVER auto-retried — a blind retry could
194
+ // double-charge credits or fork a duplicate resource. That gate lives at the
195
+ // call sites; these helpers only classify.
196
+
197
+ export interface RetryOptions {
198
+ /** Total attempts including the first (so 3 = 1 try + 2 retries). */
199
+ attempts: number;
200
+ /** Base backoff in ms; attempt n waits ~baseMs * 2^(n-1) before retrying. */
201
+ baseMs: number;
202
+ /** Upper bound on any single backoff wait (before jitter). */
203
+ maxMs: number;
204
+ }
205
+
206
+ export const DEFAULT_RETRY: RetryOptions = {
207
+ attempts: 3,
208
+ baseMs: 300,
209
+ maxMs: 2000,
210
+ };
211
+
212
+ /**
213
+ * True for an HTTP status worth retrying on an idempotent GET: any 5xx (a
214
+ * transient server/proxy fault) or 429 (rate limited — back off and retry).
215
+ * 4xx other than 429 are the caller's problem (auth, not-found, validation) and
216
+ * must NOT be retried. NB: the AI-assist-quota 429 is a POST-only path handled by
217
+ * withAssist, so retrying a 429 here (GET-only) never collides with it.
218
+ */
219
+ export function isRetryableStatus(status: number): boolean {
220
+ return status === 429 || (status >= 500 && status <= 599);
221
+ }
222
+
223
+ /**
224
+ * True for a thrown fetch error worth retrying: a network-layer failure
225
+ * (connection reset/refused, DNS blip — surfaced as a TypeError) or a request
226
+ * timeout (AbortSignal.timeout → TimeoutError; a plain AbortError is treated the
227
+ * same). Also honours an explicit `retryable:true` marker, which uploads.ts's
228
+ * putToPresignedUrl sets when it re-wraps a transient PUT failure into a friendly
229
+ * message (the original name would otherwise be lost). Our own pre-flight guards
230
+ * (assertSafeFetchUrl throws a plain Error with no name/marker) are deliberately
231
+ * NOT matched, so a refused SSRF/insecure URL fails immediately.
232
+ */
233
+ export function isRetryableNetworkError(err: unknown): boolean {
234
+ if (!(err instanceof Error)) return false;
235
+ if ((err as { retryable?: boolean }).retryable === true) return true;
236
+ return (
237
+ err.name === "TypeError" ||
238
+ err.name === "TimeoutError" ||
239
+ err.name === "AbortError"
240
+ );
241
+ }
242
+
243
+ /**
244
+ * Backoff wait before the next attempt. Exponential (baseMs·2^(attempt-1)) capped
245
+ * at maxMs, then full-jittered into [half, full] so concurrent clients don't
246
+ * synchronize their retries. `attempt` is 1-based (the wait BEFORE retry #attempt,
247
+ * i.e. after the attempt-th failure). `rand` is injectable (defaults to
248
+ * Math.random) purely so the jitter is deterministic in tests.
249
+ */
250
+ export function backoffDelayMs(
251
+ attempt: number,
252
+ opts: RetryOptions,
253
+ rand: () => number = Math.random,
254
+ ): number {
255
+ const capped = Math.min(opts.maxMs, opts.baseMs * 2 ** (attempt - 1));
256
+ // Full jitter: sleep a random duration in [capped/2, capped].
257
+ return Math.round(capped / 2 + rand() * (capped / 2));
258
+ }
259
+
122
260
  export interface NormalizedStatus {
123
261
  kind: Kind;
124
262
  slug: string;
@@ -129,6 +267,41 @@ export interface NormalizedStatus {
129
267
  error: string | null;
130
268
  }
131
269
 
270
+ /** The structured fields of an API error envelope errMessage formats from. */
271
+ export interface ApiErrorFields {
272
+ status: number;
273
+ code?: string;
274
+ message: string;
275
+ body?: unknown;
276
+ }
277
+
278
+ /**
279
+ * Formats a structured API error into a one-line agent-facing message.
280
+ *
281
+ * Lives here (not in index.ts) so the credit-field parsing — exactly the kind
282
+ * of string-shaped logic that silently regresses on a key rename — is unit
283
+ * testable: index.ts boots a live stdio MCP server at module load, so it can't
284
+ * be imported from a test (see this file's header). index.ts's errMessage is a
285
+ * thin wrapper that pulls these fields off a HubfluencerError and delegates here.
286
+ *
287
+ * For a 402 it surfaces the structured credit shortfall the server attaches so
288
+ * the agent can report exactly how short it is instead of a bare sentence. The
289
+ * canonical shape is required_credits/available_credits (tracking render +
290
+ * editor render); it falls back to the legacy required/available keys (older
291
+ * faceless 402s) so the counts always surface regardless of which the route
292
+ * returns.
293
+ */
294
+ export function formatApiErrorMessage(f: ApiErrorFields): string {
295
+ const base = `API error (HTTP ${f.status}${f.code ? `, ${f.code}` : ""}): ${f.message}`;
296
+ const body = asRecord(f.body);
297
+ const req = body.required_credits ?? body.required;
298
+ const have = body.available_credits ?? body.available;
299
+ if (req != null || have != null) {
300
+ return `${base} (needs ${req ?? "?"} credits, have ${have ?? "?"}).`;
301
+ }
302
+ return base;
303
+ }
304
+
132
305
  export function asRecord(v: unknown): Record<string, unknown> {
133
306
  return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
134
307
  }
@@ -156,6 +329,51 @@ export function normalizeStatus(
156
329
  };
157
330
  }
158
331
 
332
+ if (kind === "tracking") {
333
+ // The /tracking payload nests the render under `result` (status flows
334
+ // pending → processing → completed | failed); `source` is the upload. The
335
+ // burned-in MP4 URL lives at result.video_url. Before any render the result
336
+ // is null, so the project is non-terminal (the factory status carries the
337
+ // draft/processing/ready stage).
338
+ const result = asRecord(d.result);
339
+ const renderStatus = (result.status as string) ?? null;
340
+ const trackingUrl = (result.video_url as string) ?? null;
341
+ const ready = renderStatus === "completed" && Boolean(trackingUrl);
342
+ return {
343
+ kind,
344
+ slug,
345
+ stage: renderStatus ?? (d.status as string) ?? "unknown",
346
+ terminal: ready || renderStatus === "failed",
347
+ ready,
348
+ video_url: trackingUrl,
349
+ // The /tracking serializer (result_payload/1) emits error_message on the
350
+ // result — null until a render fails, the failure reason once it does —
351
+ // so a failed render surfaces its diagnostic here.
352
+ error: (result.error_message as string) ?? null,
353
+ };
354
+ }
355
+
356
+ if (kind === "slider") {
357
+ // The /sliders payload carries a top-level status (draft → processing →
358
+ // completed | failed) and per-slide image_urls (no single MP4). "completed"
359
+ // is written in one transaction once every slide is composited, so it always
360
+ // implies the slides are ready — mirroring the get_slider tool's contract.
361
+ // video_url stays null (a carousel is stills, not a video): wait_for_completion
362
+ // therefore never tries to download an MP4 for it, and callers read the slide
363
+ // URLs via get_slider. Kept consistent with the short/tracking shape so an
364
+ // agent can poll any kind through the same {stage,terminal,ready} fields.
365
+ const stage = (d.status as string) ?? "unknown";
366
+ return {
367
+ kind,
368
+ slug,
369
+ stage,
370
+ terminal: stage === "completed" || stage === "failed",
371
+ ready: stage === "completed",
372
+ video_url: null,
373
+ error: (d.error_message as string) ?? null,
374
+ };
375
+ }
376
+
159
377
  // editor
160
378
  const autopilot = (d.autopilot_status as string) ?? "unknown";
161
379
  const renderStatus = (latest.status as string) ?? null;
@@ -176,6 +394,46 @@ export function normalizeStatus(
176
394
  };
177
395
  }
178
396
 
397
+ /**
398
+ * A discriminator folded into make_video's start idempotency key so a re-run
399
+ * AFTER a terminal failure genuinely restarts instead of replaying the failed
400
+ * run's cached 202 for 24h (the server caches idempotent POSTs). Mirrors the
401
+ * tracking render key's anti-replay design (which folds the failed result id):
402
+ *
403
+ * - Not terminally failed (fresh draft, in flight, or completed) → "start", a
404
+ * STABLE token. A transport retry of the same start therefore reuses the key
405
+ * and dedupes (a double-start is also independently blocked server-side by
406
+ * the already-running / 409 gate), so no double charge.
407
+ * - Terminally failed → a signature of the per-attempt-varying render
408
+ * id/version (plus the failure step/stage), so a re-run reads the failed
409
+ * state, mints a DIFFERENT key, and re-enqueues the pipeline. Each subsequent
410
+ * rendered failure changes latest_render.id, so successive retries keep
411
+ * minting fresh keys rather than replaying a dead 202.
412
+ *
413
+ * Pure: derived solely from the status payload the caller already fetched.
414
+ */
415
+ export function startStateDiscriminator(kind: Kind, data: unknown): string {
416
+ const d = asRecord(data);
417
+ const latest = asRecord(d.latest_render);
418
+ const renderSig = `${latest.id ?? ""}:${latest.version ?? ""}`;
419
+
420
+ if (kind === "short") {
421
+ const stage = (d.stage as string) ?? "";
422
+ if (stage !== "failed") return "start";
423
+ return `failed:${d.failed_stage ?? ""}:${renderSig}`;
424
+ }
425
+
426
+ // editor
427
+ const autopilot = (d.autopilot_status as string) ?? "";
428
+ const renderStatus = (latest.status as string) ?? "";
429
+ const failed =
430
+ autopilot === "failed" ||
431
+ autopilot === "cancelled" ||
432
+ renderStatus === "failed";
433
+ if (!failed) return "start";
434
+ return `failed:${autopilot}:${d.autopilot_error_step ?? ""}:${renderSig}`;
435
+ }
436
+
179
437
  /** Stable idempotency key for a logical operation, so a retried call is safe. */
180
438
  export function idemKey(...parts: string[]): string {
181
439
  return createHash("sha256")
@@ -184,6 +442,172 @@ export function idemKey(...parts: string[]): string {
184
442
  .slice(0, 32);
185
443
  }
186
444
 
445
+ // ── Editor idempotency-key compositions ───────────────────────────────────────
446
+ //
447
+ // These are the exact `idemKey()` argument lists the editor create/start handlers
448
+ // send, extracted so the handler AND the golden-pin test share ONE definition.
449
+ // Previously each was hand-copied into test/handlers.test.ts, so a handler-side
450
+ // arg reorder/add/drop silently changed the real key while the test's private
451
+ // copy did not move — the tripwire could not fire. Keep these pure (no I/O) and
452
+ // keep the argument ORDER/SET/prefix stable: any change here shifts the hash and
453
+ // must be reflected in the pinned golden. The two tools deliberately use different
454
+ // field names (make_video: prompt/aspect · create_editor_ad: product_prompt/
455
+ // export_aspect_ratio) and orders, so they are separate functions on purpose.
456
+
457
+ /** make_video's editor draft-create key (`POST /editor`). */
458
+ export interface MakeVideoEditorCreateArgs {
459
+ prompt: string;
460
+ product_subject?: string;
461
+ project_intent?: string;
462
+ language?: string;
463
+ aspect?: string;
464
+ voice_id?: string;
465
+ creative_format?: string;
466
+ visual_language?: string;
467
+ theme?: string;
468
+ }
469
+
470
+ export function makeVideoEditorCreateKey(a: MakeVideoEditorCreateArgs): string {
471
+ return idemKey(
472
+ "make-editor",
473
+ a.prompt,
474
+ a.product_subject ?? "",
475
+ a.project_intent ?? "",
476
+ a.language ?? "en",
477
+ a.aspect ?? "",
478
+ a.voice_id ?? "",
479
+ a.creative_format ?? "",
480
+ a.visual_language ?? "",
481
+ a.theme ?? "",
482
+ );
483
+ }
484
+
485
+ /**
486
+ * make_video's autopilot-start key (`POST /editor/:slug/autopilot`). Folds the
487
+ * create-affecting params AND an anti-replay `startState` discriminator so a
488
+ * re-run after a terminal failure mints a fresh key and re-enqueues instead of
489
+ * replaying the server's cached 202.
490
+ */
491
+ export function makeVideoAutopilotKey(
492
+ slug: string,
493
+ a: MakeVideoEditorCreateArgs,
494
+ startState: string,
495
+ ): string {
496
+ return idemKey(
497
+ "autopilot",
498
+ slug,
499
+ a.prompt,
500
+ a.product_subject ?? "",
501
+ a.project_intent ?? "",
502
+ a.language ?? "en",
503
+ a.aspect ?? "",
504
+ a.voice_id ?? "",
505
+ a.creative_format ?? "",
506
+ a.visual_language ?? "",
507
+ a.theme ?? "",
508
+ startState,
509
+ );
510
+ }
511
+
512
+ /** create_editor_ad's draft-create and autopilot-start key argument set. */
513
+ export interface EditorAdKeyArgs {
514
+ product_prompt?: string;
515
+ product_subject?: string;
516
+ project_intent?: string;
517
+ language?: string;
518
+ creative_format?: string;
519
+ visual_language?: string;
520
+ theme?: string;
521
+ voice_id?: string;
522
+ export_aspect_ratio?: string;
523
+ }
524
+
525
+ /** create_editor_ad's editor draft-create key (`POST /editor`). */
526
+ export function editorAdCreateKey(a: EditorAdKeyArgs): string {
527
+ return idemKey(
528
+ "create-editor",
529
+ a.product_prompt ?? "",
530
+ a.product_subject ?? "",
531
+ a.project_intent ?? "",
532
+ a.language ?? "en",
533
+ a.creative_format ?? "",
534
+ a.visual_language ?? "",
535
+ a.theme ?? "",
536
+ a.voice_id ?? "",
537
+ a.export_aspect_ratio ?? "",
538
+ );
539
+ }
540
+
541
+ /**
542
+ * create_editor_ad's autopilot-start key (`POST /editor/:slug/autopilot`). Folds
543
+ * the create params (not a startState — this tool starts right after create, so
544
+ * there is no prior terminal state to guard against) so it tracks the inputs, not
545
+ * just the slug. Self-consistent per-tool only.
546
+ */
547
+ export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
548
+ return idemKey(
549
+ "autopilot",
550
+ slug,
551
+ a.product_prompt ?? "",
552
+ a.product_subject ?? "",
553
+ a.project_intent ?? "",
554
+ a.language ?? "en",
555
+ a.creative_format ?? "",
556
+ a.visual_language ?? "",
557
+ a.theme ?? "",
558
+ a.voice_id ?? "",
559
+ a.export_aspect_ratio ?? "",
560
+ );
561
+ }
562
+
563
+ /** create_slider's draft-create key (`POST /sliders`). */
564
+ export interface SliderCreateKeyArgs {
565
+ prompt: string;
566
+ mode?: string;
567
+ template?: string;
568
+ slide_count?: number;
569
+ aspect_ratio?: string;
570
+ accent_color?: string;
571
+ text_position?: string;
572
+ }
573
+
574
+ export function sliderCreateKey(a: SliderCreateKeyArgs): string {
575
+ return idemKey(
576
+ "create-slider",
577
+ a.prompt,
578
+ a.mode ?? "",
579
+ a.template ?? "",
580
+ a.slide_count === undefined ? "" : String(a.slide_count),
581
+ a.aspect_ratio ?? "",
582
+ a.accent_color ?? "",
583
+ a.text_position ?? "",
584
+ );
585
+ }
586
+
587
+ /**
588
+ * set_scene_count's per-add idempotency key for `POST /editor/:slug/segments`.
589
+ * The server dedupes add-segment by Idempotency-Key for 24h (a replay returns the
590
+ * cached 201 and inserts NOTHING). Keying only on (slug, index) meant a
591
+ * grow→shrink→grow on the same draft inside that window reused the earlier round's
592
+ * keys — the second grow replayed the cached 201s and silently added no segments.
593
+ *
594
+ * A `nonce` freshly generated ONCE per set_scene_count call salts the key so every
595
+ * grow round mints keys the server has never seen, so each round actually adds its
596
+ * segments. This does NOT weaken safety: set_scene_count re-reads the live segment
597
+ * count each call and only issues adds for the shortfall, so a whole-tool retry is
598
+ * already convergent (a repeat with the same target adds nothing because
599
+ * target ≤ current); the per-add key only guards a single call's individual POSTs,
600
+ * which the client never auto-retries anyway. `index` keeps the keys within one
601
+ * call distinct.
602
+ */
603
+ export function addSegmentKey(
604
+ slug: string,
605
+ index: number,
606
+ nonce: string,
607
+ ): string {
608
+ return idemKey("add-segment", slug, String(index), nonce);
609
+ }
610
+
187
611
  /**
188
612
  * Confines a model-supplied save_path to an allowlisted output base so a
189
613
  * prompt-injected path can't write outside it (e.g. ~/.ssh/authorized_keys).
@@ -242,3 +666,188 @@ export function inferKind(prompt: string): Kind {
242
666
  );
243
667
  return wantsEditor ? "editor" : "short";
244
668
  }
669
+
670
+ /**
671
+ * Client-side guards for the autopilot pre-launch inputs (language, brief).
672
+ * The flat-schema tools (e.g. make_video) can't carry .min/.max chains without
673
+ * tripping TS2589, so these run in-code and return a clean message (or null when
674
+ * valid) instead of relying on a raw Zod parse error. Kept in sync with the
675
+ * ranges the API enforces and the create_editor_draft schema (lang 2–10,
676
+ * product_prompt 10–5000).
677
+ */
678
+ export function validateLanguage(language: string | undefined): string | null {
679
+ if (language === undefined) return null;
680
+ const len = language.trim().length;
681
+ if (len < 2 || len > 10) {
682
+ return 'language must be a 2–10 character code (e.g. "en", "fr").';
683
+ }
684
+ return null;
685
+ }
686
+
687
+ export function validateProductPrompt(
688
+ prompt: string | undefined,
689
+ ): string | null {
690
+ // A trimmed-empty string is a no-op on every path the server exposes:
691
+ // pre-launch override handling drops blank values (leave the stored value
692
+ // untouched) and the editor prompt is valid when blank, so treat "" the same
693
+ // as undefined. Still reject 1–9 non-blank chars (too short to be a brief).
694
+ if (prompt === undefined) return null;
695
+ const len = prompt.trim().length;
696
+ if (len === 0) return null;
697
+ if (len < 10 || len > 5000) {
698
+ return "product_prompt, when provided, must be 10–5000 characters.";
699
+ }
700
+ return null;
701
+ }
702
+
703
+ /**
704
+ * Guards make_video's `kind`. It is a flat z.string() (the flat-schema TS2589
705
+ * constraint again), so validate it in code instead of silently inferring: a
706
+ * typo or an unsupported kind ("slider"/"tracking") would otherwise fall through
707
+ * to inferKind and spend on the wrong product. Accepts the documented values
708
+ * ("short", "editor", "auto") and undefined (→ infer); rejects everything else
709
+ * with a message that names the allowed values and the right tool to use.
710
+ */
711
+ export function validateKind(kind: string | undefined): string | null {
712
+ if (
713
+ kind === undefined ||
714
+ kind === "short" ||
715
+ kind === "editor" ||
716
+ kind === "auto"
717
+ ) {
718
+ return null;
719
+ }
720
+ return (
721
+ `kind must be one of: short, editor, auto (got "${kind}"). ` +
722
+ "Use create_slider for carousels or create_tracking_video for tracking overlays."
723
+ );
724
+ }
725
+
726
+ // ── create_tracking_video orchestration (pure decision logic) ────────────────
727
+ //
728
+ // The create_tracking_video handler in index.ts is one long async body that
729
+ // interleaves I/O (create, upload, poll, render) with non-trivial branching:
730
+ // resume-vs-fresh, whether the existing source is still live, whether to submit
731
+ // a render, and which idempotency keys to mint. importing index.ts to unit-test
732
+ // that branching is a non-starter (it boots a live stdio MCP server at module
733
+ // load — see this file's header), so the branching is extracted here as pure
734
+ // functions and the handler just calls them. Keeping these side-effect-free also
735
+ // makes the safety-critical render-key derivation (anti-replay-of-a-dead-202)
736
+ // directly testable.
737
+
738
+ /** The shape of GET /tracking/:slug the orchestration needs to reason about. */
739
+ export interface TrackingSnapshot {
740
+ source: { status: string } | null;
741
+ result: { id?: number; status: string; video_url?: string | null } | null;
742
+ }
743
+
744
+ /** The create_tracking_video inputs that steer the orchestration. */
745
+ export interface TrackingArgs {
746
+ video_path?: string;
747
+ slug?: string;
748
+ classes?: number[];
749
+ color?: number[];
750
+ }
751
+
752
+ export interface TrackingPlan {
753
+ /**
754
+ * Stable idempotency anchor for the create POST and the base from which the
755
+ * render key is derived. On a resume it's keyed on the slug; on a fresh call
756
+ * it folds in a per-invocation nonce so two runs over the SAME local clip path
757
+ * within the 24h idempotency window fork distinct projects instead of the
758
+ * second replaying the first's cached 201 (and appending an upload+render to
759
+ * the stale project). See index.ts for the full rationale.
760
+ */
761
+ idemBase: string;
762
+ /**
763
+ * True when the project already has a source row that is NOT `failed`.
764
+ * EditorUpload statuses are pending|processing|ready|failed; only `failed` is
765
+ * unusable. pending/processing are progressing toward ready (the API now
766
+ * surfaces an in-flight source instead of hiding it), so a live source means
767
+ * "do NOT re-upload — just wait for it", never "re-upload".
768
+ */
769
+ hasLiveSource: boolean;
770
+ /** True when the caller supplied classes or color (a specific overlay config). */
771
+ configSupplied: boolean;
772
+ /**
773
+ * True only when resuming a COMPLETED render AND new classes/color were given
774
+ * — the caller wants a different overlay than the finished video, so we hand
775
+ * the dedup decision to the backend (its combination_hash replays an identical
776
+ * config free or charges 1 credit for a changed one).
777
+ */
778
+ reRenderCompletedWithNewConfig: boolean;
779
+ /**
780
+ * Whether to (re)submit POST /tracking/:slug/render: no result yet, the prior
781
+ * one failed (and was refunded), or a completed render is being re-run with a
782
+ * new config. A render already in flight (pending/processing) is left alone.
783
+ */
784
+ shouldRender: boolean;
785
+ /**
786
+ * Per-attempt discriminator folded into the render key so it diverges exactly
787
+ * when a fresh enqueue is required: "retry:<id>" after a fail+refund (a NEW key
788
+ * so the worker re-enqueues instead of replaying the dead 202), "rerender:<id>"
789
+ * when re-rendering a completed result with new config, "new" otherwise.
790
+ */
791
+ renderAttempt: string;
792
+ /** The idempotency key for the render POST (only meaningful when shouldRender). */
793
+ renderKey: string;
794
+ }
795
+
796
+ /**
797
+ * Computes the create_tracking_video orchestration plan from the (possibly null)
798
+ * current project snapshot and the call args. Pure: no I/O, no clock, no env.
799
+ *
800
+ * `freshNonce` is threaded in rather than generated here so the runtime can pass
801
+ * randomUUID() (each fresh call forks a new project) while tests pass a fixed
802
+ * sentinel (so the derived goldens are reproducible). It is ignored on a resume
803
+ * (the slug is the stable anchor across resumes).
804
+ */
805
+ export function decideTrackingPlan(
806
+ existing: TrackingSnapshot | null,
807
+ args: TrackingArgs,
808
+ freshNonce: string,
809
+ ): TrackingPlan {
810
+ const idemBase = args.slug
811
+ ? idemKey("tracking", args.slug)
812
+ : idemKey("tracking", args.video_path ?? "", freshNonce);
813
+
814
+ // A source is "live" (do not re-upload) when it exists and isn't failed. The
815
+ // API now surfaces pending/processing sources, so an in-flight upload is
816
+ // observed here and routed to wait-not-reupload rather than forking a
817
+ // duplicate EditorUpload.
818
+ const hasLiveSource =
819
+ existing?.source != null && existing.source.status !== "failed";
820
+
821
+ const resultStatus = existing?.result?.status;
822
+ const configSupplied = args.classes !== undefined || args.color !== undefined;
823
+ const reRenderCompletedWithNewConfig =
824
+ resultStatus === "completed" && configSupplied;
825
+ const shouldRender =
826
+ !existing?.result ||
827
+ resultStatus === "failed" ||
828
+ reRenderCompletedWithNewConfig;
829
+
830
+ const renderAttempt =
831
+ resultStatus === "failed"
832
+ ? `retry:${existing?.result?.id ?? "unknown"}`
833
+ : reRenderCompletedWithNewConfig
834
+ ? `rerender:${existing?.result?.id ?? "unknown"}`
835
+ : "new";
836
+ const renderKey = idemKey(
837
+ idemBase,
838
+ "render",
839
+ renderAttempt,
840
+ JSON.stringify(args.classes ?? null),
841
+ JSON.stringify(args.color ?? null),
842
+ );
843
+
844
+ return {
845
+ idemBase,
846
+ hasLiveSource,
847
+ configSupplied,
848
+ reRenderCompletedWithNewConfig,
849
+ shouldRender,
850
+ renderAttempt,
851
+ renderKey,
852
+ };
853
+ }