@vidofy/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.
@@ -0,0 +1,455 @@
1
+ /**
2
+ * The one place that talks to Vidofy over HTTP.
3
+ *
4
+ * Everything else in this package builds a request description and hands it
5
+ * here, so door selection, authentication, retries, timeouts and error shape
6
+ * are decided once instead of in nine tools.
7
+ *
8
+ * WHAT THIS FILE IS RESPONSIBLE FOR
9
+ * · picking /app/v1 or /api/v1 from the configured mode
10
+ * · attaching the credential and the User-Agent on every call
11
+ * · multipart bodies — one form field per m_* key, exactly as the studio posts
12
+ * · carrying an Idempotency-Key when the caller supplies one
13
+ * · retrying the things that are worth retrying, and nothing else
14
+ * · turning both transport failures and API error envelopes into one error type
15
+ */
16
+ import { readFile, lstat } from 'node:fs/promises';
17
+ import { statSync } from 'node:fs';
18
+ import { basename, extname } from 'node:path';
19
+ import { randomUUID, createHash } from 'node:crypto';
20
+ import { apiPrefix, authHeaders } from './config.js';
21
+ // map/ imports nothing, so this direction cannot cycle.
22
+ import { stripProviderCost } from './map/b2c.js';
23
+ import { durationSecFromBuffer } from './media-duration.js';
24
+ /* ── errors ──────────────────────────────────────────────────────────────── */
25
+ /**
26
+ * Anything that went wrong, from either side of the wire.
27
+ *
28
+ * `code` is the machine-readable one — the API's own `error` field where there
29
+ * was a response, or a transport pseudo-code where there was not. `message` is
30
+ * what a person (or a model) should read.
31
+ */
32
+ export class VidofyError extends Error {
33
+ code;
34
+ httpStatus;
35
+ details;
36
+ constructor(code, message, httpStatus = null,
37
+ /** Extra fields the API returned, e.g. `allowed` on INVALID_MODE. */
38
+ details = {}) {
39
+ super(message);
40
+ this.code = code;
41
+ this.httpStatus = httpStatus;
42
+ this.details = details;
43
+ this.name = 'VidofyError';
44
+ }
45
+ }
46
+ /* ── retry policy ────────────────────────────────────────────────────────── */
47
+ /**
48
+ * Retry a rate limit, a gateway hiccup and a dropped connection. Nothing else.
49
+ *
50
+ * Every other 4xx is deterministic — a missing field, an unknown model, a
51
+ * revoked token, an empty balance. Retrying those burns the user's time to
52
+ * arrive at the same answer, and on a 402 it would look like the server is
53
+ * being asked to charge repeatedly.
54
+ */
55
+ function isRetryableStatus(status) {
56
+ return status === 429 || status === 502 || status === 503 || status === 504;
57
+ }
58
+ const MAX_ATTEMPTS = 4;
59
+ const BASE_DELAY_MS = 500;
60
+ /**
61
+ * Exponential backoff with full jitter, capped.
62
+ *
63
+ * Jitter matters even for one client: an agent fires several tools at once, and
64
+ * without it their retries line up and hit the same rate limit together.
65
+ * Retry-After wins when the server sends one. /api/v1 now does, on 429, and the
66
+ * value is exact rather than a guess because the limiter uses a fixed window
67
+ * (per-minute and per-day) so it knows precisely when the window turns over.
68
+ * /app/v1's own 60/min limiter
69
+ * sends none, and that door is the one this server actually speaks to, so the
70
+ * jitter below is still the live path.
71
+ */
72
+ function backoffDelayMs(attempt, retryAfterHeader) {
73
+ if (retryAfterHeader) {
74
+ const seconds = Number(retryAfterHeader);
75
+ if (Number.isFinite(seconds) && seconds >= 0) {
76
+ return Math.min(seconds * 1000, 30_000);
77
+ }
78
+ }
79
+ const ceiling = Math.min(BASE_DELAY_MS * 2 ** attempt, 8_000);
80
+ return Math.random() * ceiling;
81
+ }
82
+ /** Exported for get_status, which holds its call open rather than letting the
83
+ * agent poll in a tight loop — one definition, not a second one that drifts. */
84
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
85
+ /**
86
+ * A ceiling that applies even when the model declares none.
87
+ *
88
+ * It is not a policy about what Vidofy accepts — the server decides that. It is
89
+ * a guard on THIS process: readFile() buffers the whole file and Blob copies it
90
+ * again, so a path pointing at a 4 GB file takes the MCP server down with an
91
+ * OOM, and the AI client reports only that the connection died.
92
+ */
93
+ const HARD_MAX_BYTES = 512 * 1024 * 1024;
94
+ const DEFAULT_TIMEOUT_MS = 60_000;
95
+ /* ONE ceiling for a whole call, retries and backoff included.
96
+ *
97
+ * DEFAULT_TIMEOUT_MS is PER ATTEMPT, and attempts compose by ADDING: four of
98
+ * them plus backoff is 264s. The MCP SDK's DEFAULT_REQUEST_TIMEOUT_MSEC is
99
+ * 60s, so every one of those calls was still trying, alone, minutes after the
100
+ * client had given up and told the user it failed — and get_status/get_result,
101
+ * the two that are called over and over while a generation runs, were among
102
+ * them. `generate` was given a deadline for exactly this reason; it is the
103
+ * transport that should carry it, not one of the nine tools.
104
+ *
105
+ * 55s leaves the client a margin to hear the answer. A per-attempt timeout can
106
+ * still be shorter (generate's submit sets 50s) — this is the ceiling, not the
107
+ * allowance. */
108
+ const CALL_BUDGET_MS = 55_000;
109
+ /* Below this there is no point starting another attempt: the sleep plus a
110
+ * one-second window buys a near-certain second failure and spends the last of
111
+ * the time the caller could have used to hear about the first. */
112
+ const MIN_ATTEMPT_MS = 2_000;
113
+ /** Content types this package will attach, keyed by extension. */
114
+ const MIME = {
115
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
116
+ '.webp': 'image/webp', '.gif': 'image/gif',
117
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
118
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg',
119
+ };
120
+ function guessMime(path) {
121
+ return MIME[extname(path).toLowerCase()] ?? 'application/octet-stream';
122
+ }
123
+ /** A fresh idempotency key. Callers make ONE per logical operation. */
124
+ export function newIdempotencyKey() {
125
+ return randomUUID();
126
+ }
127
+ /**
128
+ * An idempotency key derived from WHAT IS BEING ASKED FOR, inside a window.
129
+ *
130
+ * The server dedupes on (user, origin, m_client_request_id) and this package
131
+ * sent a randomUUID() per invocation, so that tuple could never repeat and
132
+ * the dedupe could never fire: two identical generate calls were two
133
+ * generations and two charges. The mechanism existed on both sides and was
134
+ * joined by a value guaranteed to differ.
135
+ *
136
+ * NO TIME COMPONENT IN THE KEY, deliberately — that was tried and is wrong.
137
+ * The expiry a derived key needs (the same prompt next week must generate
138
+ * again, not replay) is a property of the LOOKUP, and it now lives there:
139
+ * The server's idempotent-lookup takes a max age and the submit
140
+ * handler passes 60 seconds for an MCP caller. Hashing a time bucket into the
141
+ * key instead makes a tumbling grid, so two calls seconds apart miss each
142
+ * other whenever a boundary falls between them — measured on the first live
143
+ * pair tried: 11 seconds apart, two jobs, two charges. A bound on the query
144
+ * is a real "within 60s of each other" at any alignment.
145
+ *
146
+ * Files are identified by path AND by size+mtime, so editing an image and
147
+ * re-running inside the window is a different request rather than a cached
148
+ * answer. A path that cannot be stat'd contributes its raw path and the real
149
+ * complaint arrives later, from the code whose job that is.
150
+ */
151
+ export function contentIdempotencyKey(parts, filePaths = []) {
152
+ const fileTags = [...filePaths].sort().map((p) => {
153
+ try {
154
+ const s = statSync(p);
155
+ return `${p}:${s.size}:${Math.floor(s.mtimeMs)}`;
156
+ }
157
+ catch {
158
+ return p;
159
+ }
160
+ });
161
+ // Sorted keys: {a,b} and {b,a} are the same request and must not become
162
+ // two. JSON.stringify preserves insertion order, which the caller's object
163
+ // literal does not control.
164
+ const canonical = JSON.stringify(parts, Object.keys(parts).sort());
165
+ return createHash('sha256')
166
+ .update(`${canonical}\n${fileTags.join('\n')}`)
167
+ .digest('hex');
168
+ }
169
+ async function buildBody(opts) {
170
+ if (opts.method === 'GET')
171
+ return { body: undefined, uploadBytes: 0 };
172
+ const entries = Object.entries(opts.form ?? {}).filter((e) => e[1] !== undefined);
173
+ if (!opts.files || opts.files.length === 0) {
174
+ const params = new URLSearchParams();
175
+ for (const [k, v] of entries)
176
+ params.append(k, String(v));
177
+ return { body: params, uploadBytes: 0 };
178
+ }
179
+ let uploadBytes = 0;
180
+ const fd = new FormData();
181
+ for (const [k, v] of entries)
182
+ fd.append(k, String(v));
183
+ for (const f of opts.files) {
184
+ let bytes;
185
+ try {
186
+ /* lstat, NOT stat: stat follows symlinks, so a file the agent was
187
+ told to send as "photo.png" could be a link to ~/.ssh/id_rsa and
188
+ every check below would inspect the harmless name while readFile
189
+ returned the key. The link itself is refused instead. */
190
+ const info = await lstat(f.path);
191
+ if (info.isSymbolicLink()) {
192
+ throw new VidofyError('FILE_IS_SYMLINK', `Refusing to upload a symbolic link: ${f.path}. Pass the real file.`);
193
+ }
194
+ if (!info.isFile()) {
195
+ throw new VidofyError('FILE_NOT_A_FILE', `Not a file: ${f.path}`);
196
+ }
197
+ /* Checked BEFORE the read, against the model's own limits, for two
198
+ separate reasons. The size one is ours: a 40 MB file sent to a
199
+ 10 MB slot costs the user the whole upload to arrive at a 422 it
200
+ cannot diagnose. The extension one is the user's: the path comes
201
+ from the conversation, so anything that talks the model into
202
+ naming a private file gets those bytes off the machine. This does
203
+ not make that safe — a real .png anywhere on disk still passes —
204
+ which is why `generate` is not readOnly and the client shows the
205
+ path to the user before it runs. */
206
+ /* Both sides normalised to a bare, lower-case extension. The model
207
+ declares them WITHOUT a dot ("jpg", "png" — straight out of
208
+ m_upload_*_settings.allowed_extensions) while extname() returns
209
+ one (".png"), so comparing them raw refuses every legitimate
210
+ upload. Accepting either form here means a caller cannot get it
211
+ subtly wrong. */
212
+ const bare = (e) => e.trim().toLowerCase().replace(/^\./, '');
213
+ const ext = bare(extname(f.path));
214
+ const accepted = (f.accept ?? []).map(bare).filter((e) => e !== '');
215
+ /* No allowlist means REFUSE, not "allow anything".
216
+ *
217
+ * This read `accepted.length > 0 && …` until 2026-09-07, so a slot
218
+ * that declared no extensions switched the check off entirely — and
219
+ * 10+ active models declare none. The caller now always resolves
220
+ * the server's own defaults first (schema.ts resolveUploadRules),
221
+ * so an empty list here means the limits are genuinely unknown, and
222
+ * a file whose type we cannot vouch for is exactly the one not to
223
+ * read off the user's disk. */
224
+ if (accepted.length === 0) {
225
+ throw new VidofyError('UPLOAD_LIMITS_UNKNOWN', `No accepted file types are known for ${f.field}; refusing to upload ` +
226
+ `${basename(f.path)}. Call get_model to see the slot's limits.`);
227
+ }
228
+ if (!accepted.includes(ext)) {
229
+ throw new VidofyError('FILE_TYPE_REJECTED', `${basename(f.path)} is ${ext ? '.' + ext : 'extensionless'}; ` +
230
+ `${f.field} accepts ${accepted.map((e) => '.' + e).join(', ')}.`);
231
+ }
232
+ const limitBytes = Math.min(f.maxSizeMb !== undefined ? f.maxSizeMb * 1024 * 1024 : HARD_MAX_BYTES, HARD_MAX_BYTES);
233
+ if (info.size > limitBytes) {
234
+ throw new VidofyError('FILE_TOO_LARGE', `${basename(f.path)} is ${(info.size / 1048576).toFixed(1)} MB; ` +
235
+ `${f.field} accepts up to ${(limitBytes / 1048576).toFixed(0)} MB.`);
236
+ }
237
+ bytes = await readFile(f.path);
238
+ /* Length, checked HERE because here it is free.
239
+ *
240
+ * The bytes are already in memory and nothing has gone on the wire
241
+ * yet, so reading the container costs no I/O and still saves the
242
+ * whole upload — which is the point: nine models cap what you send
243
+ * (30s of video on lipsync and motion-control, 30-600s of audio),
244
+ * and a 200 MB clip otherwise travels in full to be told it is
245
+ * fifteen seconds too long.
246
+ *
247
+ * null means the format does not state its length exactly, and
248
+ * then nothing is refused — the server checks authoritatively and
249
+ * says so clearly, before charging. Refusing on an inferred number
250
+ * would trade a saved upload for a rejected valid file. */
251
+ const cap = f.maxDurationSec ?? 0;
252
+ if (cap > 0) {
253
+ const seconds = durationSecFromBuffer(bytes, extname(f.path));
254
+ // Ceil to match the server, which compares ceil(duration)
255
+ // against the same cap.
256
+ if (seconds !== null && Math.ceil(seconds) > cap) {
257
+ throw new VidofyError('FILE_TOO_LONG', `${basename(f.path)} is ${Math.ceil(seconds)}s; ` +
258
+ `${f.field} accepts up to ${cap}s. Trim it and try again — ` +
259
+ 'nothing was uploaded and nothing was charged.');
260
+ }
261
+ }
262
+ }
263
+ catch (err) {
264
+ if (err instanceof VidofyError)
265
+ throw err;
266
+ throw new VidofyError('FILE_UNREADABLE', `Could not read ${f.path}: ${err instanceof Error ? err.message : String(err)}`);
267
+ }
268
+ // One form field per m_* key — the same shape the studio's FormData
269
+ // produces, which is why the server needs no special case for us.
270
+ fd.append(f.field, new Blob([bytes], { type: guessMime(f.path) }), basename(f.path));
271
+ uploadBytes += bytes.length;
272
+ }
273
+ return { body: fd, uploadBytes };
274
+ }
275
+ /** Strip anything that could echo the credential back into a log or a tool result. */
276
+ function redact(text, cfg) {
277
+ return cfg.credential ? text.split(cfg.credential).join('«credential»') : text;
278
+ }
279
+ /**
280
+ * Perform one request, with retries.
281
+ *
282
+ * Returns the parsed JSON body on success. Throws VidofyError on anything else,
283
+ * including a 200 whose envelope says `success: false`.
284
+ */
285
+ export async function request(cfg, opts) {
286
+ const url = new URL(cfg.baseUrl + apiPrefix(cfg) + '/' + opts.path.replace(/^\/+/, ''));
287
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
288
+ if (v !== undefined)
289
+ url.searchParams.set(k, String(v));
290
+ }
291
+ const headers = {
292
+ ...authHeaders(cfg),
293
+ 'User-Agent': cfg.userAgent,
294
+ Accept: 'application/json',
295
+ };
296
+ if (opts.idempotencyKey)
297
+ headers['Idempotency-Key'] = opts.idempotencyKey;
298
+ let lastError = null;
299
+ /* Built ONCE, before the loop.
300
+ *
301
+ * It used to be rebuilt per attempt, on the theory that a consumed FormData
302
+ * is not replayable. It is: undici re-reads the Blob on each send. What the
303
+ * old placement actually did was re-read every file from disk on every
304
+ * retry — four disk reads and four full uploads for one 20 MB image — and,
305
+ * worse, a file edited between attempts went out as different bytes under
306
+ * the SAME Idempotency-Key, which is precisely the case that key exists to
307
+ * make identical. */
308
+ const { body, uploadBytes } = await buildBody(opts);
309
+ // Per-call cap; see RequestOptions.maxAttempts.
310
+ const attempts = Math.max(1, Math.min(opts.maxAttempts ?? MAX_ATTEMPTS, MAX_ATTEMPTS));
311
+ /* The wall clock for the whole call — see CALL_BUDGET_MS. Fixed here,
312
+ * before the first attempt, so it bounds the attempts AND the backoff
313
+ * between them rather than restarting with each one. */
314
+ const deadline = Date.now() + CALL_BUDGET_MS;
315
+ const remainingMs = () => deadline - Date.now();
316
+ /* Is there time for another attempt, and if so wait out the backoff.
317
+ *
318
+ * Returns false when the attempts are spent OR the budget is — and the
319
+ * second half is the point: without it, a fourth attempt was still being
320
+ * started long after the client had stopped listening. Only sleeps when it
321
+ * is going to say yes, so a caller writing
322
+ * `if (isRetryable(...) && await canRetry(...)) continue;`
323
+ * pays nothing when the status is not retryable. */
324
+ const canRetry = async (attempt, retryAfter) => {
325
+ if (attempt >= attempts - 1)
326
+ return false;
327
+ const delay = backoffDelayMs(attempt, retryAfter);
328
+ if (remainingMs() - delay < MIN_ATTEMPT_MS)
329
+ return false;
330
+ await sleep(delay);
331
+ return true;
332
+ };
333
+ for (let attempt = 0; attempt < attempts; attempt++) {
334
+ /* What THIS attempt gets: its own timeout, or whatever is left of the
335
+ * call's budget, whichever is smaller. The floor keeps
336
+ * AbortSignal.timeout out of the range where it aborts instantly — a
337
+ * request that never left is reported as a timeout it did not have. */
338
+ const attemptTimeoutMs = Math.max(1_000, Math.min(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, remainingMs()));
339
+ /* `body` is OMITTED rather than passed as undefined: with
340
+ exactOptionalPropertyTypes a GET carrying `body: undefined` is not
341
+ the same thing as a GET with no body, and undici rejects the former. */
342
+ const init = {
343
+ method: opts.method,
344
+ headers,
345
+ signal: AbortSignal.timeout(attemptTimeoutMs),
346
+ /* Never follow a redirect. undici strips Authorization across
347
+ origins but forwards X-API-Key, so a redirect to another host
348
+ would hand that credential over. Nothing on /app/v1 or /api/v1
349
+ redirects; if one starts, failing is the right answer. */
350
+ redirect: 'error',
351
+ };
352
+ if (body !== undefined)
353
+ init.body = body;
354
+ let res;
355
+ try {
356
+ res = await fetch(url, init);
357
+ }
358
+ catch (err) {
359
+ const name = err instanceof Error ? err.name : '';
360
+ const isTimeout = name === 'TimeoutError' || name === 'AbortError';
361
+ lastError = new VidofyError(isTimeout ? 'TIMEOUT' : 'NETWORK_ERROR', isTimeout
362
+ // The EFFECTIVE timeout, which is not always opts.timeoutMs:
363
+ // a later attempt gets whatever is left of the call budget,
364
+ // and reporting the requested figure would name a wait that
365
+ // did not happen.
366
+ ? `Vidofy did not answer within ${Math.round(attemptTimeoutMs / 1000)}s.`
367
+ : `Could not reach ${url.origin}: ${redact(err instanceof Error ? err.message : String(err), cfg)}`);
368
+ if (await canRetry(attempt, null))
369
+ continue;
370
+ throw lastError;
371
+ }
372
+ /* Reading the body can fail on its own — the headers arrived, then the
373
+ connection dropped or the per-attempt timeout fired mid-stream. That
374
+ used to throw a raw TypeError/DOMException straight out of this
375
+ function: not a VidofyError, and NOT retried, even though the very
376
+ same failure one line earlier (during fetch) is retried. Same class
377
+ of failure, same treatment. */
378
+ let text;
379
+ try {
380
+ text = await res.text();
381
+ }
382
+ catch (err) {
383
+ const name = err instanceof Error ? err.name : '';
384
+ const isTimeout = name === 'TimeoutError' || name === 'AbortError';
385
+ lastError = new VidofyError(isTimeout ? 'TIMEOUT' : 'NETWORK_ERROR', `Vidofy answered ${res.status} but the response body did not arrive: ` +
386
+ redact(err instanceof Error ? err.message : String(err), cfg));
387
+ if (await canRetry(attempt, res.headers.get('retry-after')))
388
+ continue;
389
+ throw lastError;
390
+ }
391
+ let parsed = null;
392
+ try {
393
+ parsed = text === '' ? null : JSON.parse(text);
394
+ }
395
+ catch {
396
+ /* Not JSON. An admin account gets HTML back from some endpoints,
397
+ and a proxy error page is HTML too — either way, saying "the
398
+ server did not return JSON" beats a JSON.parse stack trace. */
399
+ if (isRetryableStatus(res.status) && await canRetry(attempt, res.headers.get('retry-after'))) {
400
+ continue;
401
+ }
402
+ /* 413 is the one status that reliably arrives as HTML, because
403
+ nginx/PHP refuse the body before any Vidofy code runs — so
404
+ there is no JSON envelope to read and no message to relay.
405
+ Saying "non-JSON body" there would hide the only fact that
406
+ matters, which is that the upload was too big for the server
407
+ and not for the model. */
408
+ if (res.status === 413) {
409
+ throw new VidofyError('UPLOAD_TOO_LARGE', `Vidofy refused the upload before reading it: ${(uploadBytes / 1048576).toFixed(1)} MB ` +
410
+ 'exceeded the server\'s total request limit. Send fewer or smaller files — ' +
411
+ 'the per-file limits from get_model are separate and were satisfied.', 413);
412
+ }
413
+ throw new VidofyError('NON_JSON_RESPONSE', `Vidofy returned ${res.status} with a non-JSON body (${text.length} bytes).`, res.status);
414
+ }
415
+ const envelope = (parsed ?? {});
416
+ const apiCode = typeof envelope['error'] === 'string' ? envelope['error'] : null;
417
+ const apiMessage = typeof envelope['message'] === 'string' ? envelope['message'] : null;
418
+ if (res.ok && envelope['success'] !== false) {
419
+ return parsed;
420
+ }
421
+ /* A generation that FAILED is not a failed call.
422
+ *
423
+ * The status endpoint answers HTTP 200 with success:false and
424
+ * error:'FAILED'|'ERROR'|'BLOCKED' for a job that reached a terminal
425
+ * failure — the full data object is right there beside it. Treating
426
+ * that as a transport error threw before the mapper ever ran, so
427
+ * get_status could never report {done:true, status:'failed'} and the
428
+ * agent could not tell "your video was blocked" from "Vidofy is
429
+ * unreachable". It also made three of the five entries in the mapper's
430
+ * TERMINAL set dead code, covering thousands of real rows.
431
+ *
432
+ * Only the callers that read a job set this, and only on a 200 — every
433
+ * other envelope error still throws. */
434
+ if (res.ok && opts.allowEnvelopeError) {
435
+ return parsed;
436
+ }
437
+ // An error, from either the status line or the envelope.
438
+ const { success: _s, error: _e, message: _m, ...rest } = envelope;
439
+ lastError = new VidofyError(apiCode ?? `HTTP_${res.status}`, redact(apiMessage ?? `Vidofy answered ${res.status}.`, cfg), res.status,
440
+ /* Redacted AND cost-stripped, same as the message beside it.
441
+ index.ts serialises `details` into the tool result, so anything
442
+ left here reaches the model — including the provider cost the
443
+ rest of this package works to keep in-house. */
444
+ stripProviderCost(JSON.parse(redact(JSON.stringify(rest), cfg))));
445
+ if (isRetryableStatus(res.status) && await canRetry(attempt, res.headers.get('retry-after'))) {
446
+ continue;
447
+ }
448
+ throw lastError;
449
+ }
450
+ /* Unreachable: every path above either returns or throws on the last
451
+ attempt. Present so the function is total rather than relying on that
452
+ reasoning staying true. */
453
+ throw lastError ?? new VidofyError('UNKNOWN', 'Request failed for an unknown reason.');
454
+ }
455
+ //# sourceMappingURL=backend.js.map
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Where the server points, and which credential it carries.
3
+ *
4
+ * ONE credential, ONE door — never both. The two modes bill different wallets:
5
+ *
6
+ * VIDOFY_TOKEN (vmt_…) → the user's own coins (account mode — served)
7
+ * VIDOFY_API_KEY (vky_…) → a different balance (key mode — refused)
8
+ *
9
+ * Mixing them would mean a caller could not tell which balance a generation
10
+ * was going to spend until after it spent it, so a request carrying both is
11
+ * refused at startup rather than resolved by precedence.
12
+ *
13
+ * Key mode is DETECTED but NOT SERVED, and that is the settled shape of the
14
+ * product rather than a gap (owner decision, 2026-09-11): this server is for
15
+ * personal Vidofy accounts and spends their own coins. It is detected only so
16
+ * that a key can be refused with an explanation instead of failing later as an
17
+ * unexplained 401.
18
+ *
19
+ * Detected rather than ignored so that someone who sets VIDOFY_API_KEY is told
20
+ * where to go, instead of watching the server start and then fail on every
21
+ * call. See the refusal in index.ts.
22
+ */
23
+ /** Which wallet this process will spend from. */
24
+ export type Mode = 'account' | 'key';
25
+ export interface Config {
26
+ mode: Mode;
27
+ /** The raw credential. Never logged, never echoed in a tool result. */
28
+ credential: string;
29
+ /** Origin only, no trailing slash — e.g. https://vidofy.ai */
30
+ baseUrl: string;
31
+ /** Sent on every request so usage can be attributed. */
32
+ userAgent: string;
33
+ version: string;
34
+ /**
35
+ * This connector's own canonical resource (RFC 8707), when the credential
36
+ * came from an OAuth flow.
37
+ *
38
+ * Declared to /app/v1 on every call so the server can check the token's
39
+ * audience. Undefined for the stdio package and for a
40
+ * hand-made token, and that absence is meaningful rather than missing: it
41
+ * pairs with a token that declares no resource, and a hand-made token is refused at a
42
+ * connector precisely because the connector always declares one.
43
+ */
44
+ resource?: string;
45
+ }
46
+ export declare class ConfigError extends Error {
47
+ }
48
+ /**
49
+ * Read the environment into a Config, or throw a message a human can act on.
50
+ *
51
+ * @param env Defaults to process.env; injectable so this is testable without
52
+ * mutating the real environment.
53
+ */
54
+ export declare function loadConfig(env?: NodeJS.ProcessEnv, version?: string): Config;
55
+ /**
56
+ * A Config for ONE `vmt_` token, with the origin still taken from the process
57
+ * environment.
58
+ *
59
+ * This exists for the remote transport, where the two halves of a Config arrive
60
+ * from different places and at different times: the origin is server
61
+ * configuration, fixed at boot, while the credential belongs to whoever is
62
+ * making this particular request. loadConfig cannot serve that case — it reads
63
+ * the credential from the environment, which in a multi-user process would mean
64
+ * every caller spending one account's coins.
65
+ *
66
+ * The prefix check is deliberately the SAME one loadConfig applies. A remote
67
+ * caller pasting `vky_` into an Authorization header deserves the answer the
68
+ * local user gets, not a 401 that says nothing.
69
+ *
70
+ * @throws ConfigError — the caller turns it into a 401 with a readable message.
71
+ */
72
+ export declare function configForToken(token: string, env?: NodeJS.ProcessEnv, version?: string, resource?: string): Config;
73
+ /**
74
+ * Validate and normalise VIDOFY_API_BASE.
75
+ *
76
+ * Factored out of loadConfig so the remote transport cannot end up with a
77
+ * weaker check than the local one — the checks below are the whole reason a
78
+ * hostile value in claude_desktop_config.json cannot redirect a credential, and
79
+ * a second copy of them would be a second chance to get one wrong.
80
+ */
81
+ export declare function resolveBaseUrl(env?: NodeJS.ProcessEnv): string;
82
+ /** The credential header for this mode. Kept next to loadConfig so the two cannot drift. */
83
+ export declare function authHeaders(cfg: Config): Record<string, string>;
84
+ /** `/app/v1` or `/api/v1` — the door this mode speaks to. */
85
+ export declare function apiPrefix(cfg: Config): string;