@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.
package/dist/log.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * One line of diagnostics, on stderr.
3
+ *
4
+ * WHY STDERR, ALWAYS: on the stdio transport, stdout IS the protocol. A single
5
+ * stray `console.log` there is framed as a JSON-RPC message, the client fails to
6
+ * parse it, and the connection dies with an error that names nothing. There is no
7
+ * "just this once" for stdout in this package.
8
+ *
9
+ * WHY ITS OWN MODULE, as of 2026-09-13: it used to live in index.ts, which every
10
+ * other module already imports FROM — so `tools/generation.ts` importing `log` from
11
+ * index.ts made a cycle (index → tools/generation → index). It happened to work,
12
+ * because `log` is only called from inside functions that run long after both
13
+ * modules are evaluated, and that is exactly the kind of "works by luck" that
14
+ * breaks the first time somebody logs something at module top level. A leaf module
15
+ * with no imports of its own cannot participate in a cycle at all.
16
+ *
17
+ * index.ts re-exports it so existing callers keep working.
18
+ */
19
+ export declare function log(message: string): void;
package/dist/log.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * One line of diagnostics, on stderr.
3
+ *
4
+ * WHY STDERR, ALWAYS: on the stdio transport, stdout IS the protocol. A single
5
+ * stray `console.log` there is framed as a JSON-RPC message, the client fails to
6
+ * parse it, and the connection dies with an error that names nothing. There is no
7
+ * "just this once" for stdout in this package.
8
+ *
9
+ * WHY ITS OWN MODULE, as of 2026-09-13: it used to live in index.ts, which every
10
+ * other module already imports FROM — so `tools/generation.ts` importing `log` from
11
+ * index.ts made a cycle (index → tools/generation → index). It happened to work,
12
+ * because `log` is only called from inside functions that run long after both
13
+ * modules are evaluated, and that is exactly the kind of "works by luck" that
14
+ * breaks the first time somebody logs something at module top level. A leaf module
15
+ * with no imports of its own cannot participate in a cycle at all.
16
+ *
17
+ * index.ts re-exports it so existing callers keep working.
18
+ */
19
+ export function log(message) {
20
+ process.stderr.write(`[vidofy-mcp] ${message}\n`);
21
+ }
22
+ //# sourceMappingURL=log.js.map
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Translate Vidofy's responses into one shape the agent always sees.
3
+ *
4
+ * TWO RULES GOVERN THIS FILE.
5
+ *
6
+ * 1. BRANCH ON THE PAYLOAD, NEVER ON THE CONFIGURED MODE.
7
+ * The server decides its response shape from the stored row, not from how
8
+ * the caller authenticated — the row's own origin selects the B2B shape. So a
9
+ * media row created with an API key, fetched later through /app/v1 with a
10
+ * personal token, comes back in the B2B shape — clean names, no m_ prefix.
11
+ * A mapper that
12
+ * keyed off "we are in account mode" would read every field as undefined and
13
+ * report a successful generation as an empty result.
14
+ *
15
+ * 2. PROVIDER COST NEVER REACHES THE AGENT.
16
+ * `m_api_cost` / `aul_api_cost` / `total_api_cost` are what Vidofy paid the
17
+ * upstream provider — the margin, from which the markup is one division
18
+ * away. They are stripped recursively, by name, from everything.
19
+ *
20
+ * (An earlier version of this comment spelled out a real row's coins and
21
+ * provider cost as an illustration. That pair IS the markup — writing the
22
+ * secret into the file whose job is to keep it. Removed 2026-09-07.)
23
+ *
24
+ * `cost_usd` is NOT one of them and is deliberately kept — but ONLY on
25
+ * status and result, where the server builds it from the partner-facing
26
+ * price. On the pricing endpoint the same name carries the provider cost
27
+ * instead, which is why it is emitted from the B2B shape alone and never
28
+ * read here; see the note beside that guard below. Partner cost is
29
+ * published, provider cost stays internal, and the NAME does not tell the
30
+ * two apart.
31
+ */
32
+ /**
33
+ * Remove every provider-cost field, at any depth.
34
+ *
35
+ * Recursive rather than a fixed list of paths: these fields sit in different
36
+ * places in each response shape, and a new endpoint would otherwise leak
37
+ * silently until someone noticed. Removing a key that is not there costs
38
+ * nothing; missing one costs the margin.
39
+ */
40
+ export declare function stripProviderCost<T>(value: T, depth?: number): T;
41
+ export interface GenerationStatus {
42
+ id: string | null;
43
+ /** processing · success · error · failed · blocked */
44
+ status: string | null;
45
+ done: boolean;
46
+ mode: string | null;
47
+ model: string | null;
48
+ credits_charged: number | null;
49
+ estimated_seconds: number | null;
50
+ elapsed_seconds: number | null;
51
+ created_at: string | null;
52
+ completed_at: string | null;
53
+ error: string | null;
54
+ /** Present only on rows billed to the API credit wallet — the partner's own price. */
55
+ cost_usd?: number | null;
56
+ }
57
+ /**
58
+ * @param payload The whole status response, from either door and either shape.
59
+ */
60
+ export declare function mapStatus(payload: unknown): GenerationStatus;
61
+ /**
62
+ * `estimated_seconds` is deliberately NOT part of a result.
63
+ *
64
+ * The result endpoint does not return it — only status does, from the estimate
65
+ * snapshotted at submit — so spreading the status type in whole put the key
66
+ * here with a null in it: 26 from get_status, then null
67
+ * from get_result on the same generation. A field that empties itself between
68
+ * two calls reads as data loss.
69
+ *
70
+ * Omitted rather than filled in, because on a FINISHED job an estimate has no
71
+ * consumer — the actual time is right there — and the estimate is not worth
72
+ * propagating anyway: measured 2026-09-11, real time is 6.17x it for images
73
+ * and 2.02x for video.
74
+ */
75
+ export interface GenerationResult extends Omit<GenerationStatus, 'estimated_seconds'> {
76
+ media_type: string | null;
77
+ url: string | null;
78
+ thumbnail_url: string | null;
79
+ dimensions: string | null;
80
+ /** How long the URL stays valid, in words, because it is not forever. */
81
+ url_note: string | null;
82
+ /**
83
+ * Bytes of the output file, or null when the row never recorded one.
84
+ *
85
+ * Read so a preview can be refused BEFORE it is fetched: outputs average
86
+ * 2.5 MB and reach 27 MB, and a file too large to inline should cost no
87
+ * download at all. null means unknown, never empty — the fetch falls back
88
+ * to Content-Length in that case.
89
+ */
90
+ output_size: number | null;
91
+ /**
92
+ * A link that SAVES the file rather than displaying it — presigned by the
93
+ * server with Content-Disposition: attachment. Short-lived, ten minutes:
94
+ * it is clicked at once or not at all.
95
+ *
96
+ * The card's Download button has no other way to exist. MCP Apps runs the
97
+ * view in a sandboxed iframe and defines NO download method — the whole
98
+ * view-to-host list is ui/open-link, ui/message, ui/request-display-mode,
99
+ * ui/update-model-context, tools/call, resources/read, ping. Opening this
100
+ * with ui/open-link turns a documented navigation into a real save.
101
+ */
102
+ download_url: string | null;
103
+ /**
104
+ * Does the file behind those URLs carry the Vidofy watermark?
105
+ *
106
+ * Stated by the server (`m_watermarked`), not guessed here. The
107
+ * watermarked copy exists only for a B2C row with m_public='on' that is not
108
+ * audio, while a B2B public row is uploaded clean — so a public URL alone
109
+ * does not tell the two apart, and neither does this server's own mode.
110
+ *
111
+ * false when the server does not send the field at all, which is what an
112
+ * older deployment does: the card then simply offers no upgrade button
113
+ * rather than promising to remove a watermark that may not be there.
114
+ */
115
+ watermarked: boolean;
116
+ /**
117
+ * How to feed this result into the NEXT generation.
118
+ *
119
+ * Present so the agent does not download the media and upload it again to
120
+ * chain — the file is already in Vidofy's storage, and the server can
121
+ * point a new job straight at it.
122
+ */
123
+ reuse_as_input: string | null;
124
+ }
125
+ export declare function mapResult(payload: unknown): GenerationResult;
126
+ export declare class CostUnavailableError extends Error {
127
+ }
128
+ /**
129
+ * Pull the number out of a model-credits response.
130
+ *
131
+ * `cost_credits` is NOT always a number. For a staff account the same endpoint
132
+ * returns an HTML debug string — "Price: <provider cost> USD <br> Coins: <n>" —
133
+ * because the handler enriches it internally. An agent that forwarded that to a user
134
+ * would be quoting HTML as a price, and one that did arithmetic on it would get
135
+ * NaN and silently charge whatever the submit costs.
136
+ *
137
+ * @param unit What the number means in this mode, for the message.
138
+ */
139
+ export declare function readCostCredits(payload: unknown, unit: 'coins' | 'credits'): number;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Translate Vidofy's responses into one shape the agent always sees.
3
+ *
4
+ * TWO RULES GOVERN THIS FILE.
5
+ *
6
+ * 1. BRANCH ON THE PAYLOAD, NEVER ON THE CONFIGURED MODE.
7
+ * The server decides its response shape from the stored row, not from how
8
+ * the caller authenticated — the row's own origin selects the B2B shape. So a
9
+ * media row created with an API key, fetched later through /app/v1 with a
10
+ * personal token, comes back in the B2B shape — clean names, no m_ prefix.
11
+ * A mapper that
12
+ * keyed off "we are in account mode" would read every field as undefined and
13
+ * report a successful generation as an empty result.
14
+ *
15
+ * 2. PROVIDER COST NEVER REACHES THE AGENT.
16
+ * `m_api_cost` / `aul_api_cost` / `total_api_cost` are what Vidofy paid the
17
+ * upstream provider — the margin, from which the markup is one division
18
+ * away. They are stripped recursively, by name, from everything.
19
+ *
20
+ * (An earlier version of this comment spelled out a real row's coins and
21
+ * provider cost as an illustration. That pair IS the markup — writing the
22
+ * secret into the file whose job is to keep it. Removed 2026-09-07.)
23
+ *
24
+ * `cost_usd` is NOT one of them and is deliberately kept — but ONLY on
25
+ * status and result, where the server builds it from the partner-facing
26
+ * price. On the pricing endpoint the same name carries the provider cost
27
+ * instead, which is why it is emitted from the B2B shape alone and never
28
+ * read here; see the note beside that guard below. Partner cost is
29
+ * published, provider cost stays internal, and the NAME does not tell the
30
+ * two apart.
31
+ */
32
+ /** Field names that carry Vidofy's own cost. None may ever be returned. */
33
+ const PROVIDER_COST_KEYS = new Set(['m_api_cost', 'aul_api_cost', 'total_api_cost', 'api_cost']);
34
+ /**
35
+ * Remove every provider-cost field, at any depth.
36
+ *
37
+ * Recursive rather than a fixed list of paths: these fields sit in different
38
+ * places in each response shape, and a new endpoint would otherwise leak
39
+ * silently until someone noticed. Removing a key that is not there costs
40
+ * nothing; missing one costs the margin.
41
+ */
42
+ export function stripProviderCost(value, depth = 0) {
43
+ /* A depth stop, because this runs on whatever the server sent.
44
+ * Unbounded recursion on a deep or self-referential payload throws
45
+ * RangeError out of the mapper, which the agent then reads as an
46
+ * unexplained crash instead of a result. At 64 levels every real Vidofy
47
+ * response is long finished; anything deeper is truncated rather than
48
+ * trusted, since a value this function cannot see into is a value it
49
+ * cannot promise is clean. */
50
+ if (depth > 64)
51
+ return null;
52
+ if (Array.isArray(value)) {
53
+ return value.map((v) => stripProviderCost(v, depth + 1));
54
+ }
55
+ if (value !== null && typeof value === 'object') {
56
+ /* Null-prototype: assigning a key named `__proto__` onto a plain `{}`
57
+ sets the prototype instead of a property, so a server response
58
+ carrying {"__proto__":{"m_status":"success"}} made every later
59
+ lookup inherit "success" — a failed job reading as finished. With no
60
+ prototype there is nothing to hijack. */
61
+ const out = Object.create(null);
62
+ for (const [k, v] of Object.entries(value)) {
63
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype')
64
+ continue;
65
+ // Case-insensitive: the server emits lower-case today, and that is
66
+ // the only reason a match ever succeeded. Comparing exactly makes
67
+ // the guard depend on the server's spelling never changing.
68
+ if (PROVIDER_COST_KEYS.has(k.toLowerCase()))
69
+ continue;
70
+ out[k] = stripProviderCost(v, depth + 1);
71
+ }
72
+ return out;
73
+ }
74
+ return value;
75
+ }
76
+ /* ── helpers ─────────────────────────────────────────────────────────────── */
77
+ const rec = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) ? v : {};
78
+ const str = (v) => {
79
+ if (v === null || v === undefined)
80
+ return null;
81
+ const s = String(v);
82
+ return s === '' ? null : s;
83
+ };
84
+ const int = (v) => {
85
+ const n = typeof v === 'number' ? v : typeof v === 'string' && v !== '' ? Number(v) : NaN;
86
+ return Number.isFinite(n) ? n : null;
87
+ };
88
+ /**
89
+ * Every state a row can END in. Measured from the server, not assumed:
90
+ * success · pending · error · failed · blocked · deleted_media
91
+ *
92
+ * `deleted_media` is the one that is easy to miss and expensive to miss — it is
93
+ * what retention leaves behind after the media is swept (30 days free, 180 for
94
+ * a subscriber), the server does not classify it as an error, and rows already
95
+ * carry it today. Off this list the agent sees done:false on a
96
+ * row that will never change again and polls until something else stops it.
97
+ */
98
+ const TERMINAL = new Set(['success', 'error', 'failed', 'blocked', 'deleted_media']);
99
+ /**
100
+ * @param payload The whole status response, from either door and either shape.
101
+ */
102
+ export function mapStatus(payload) {
103
+ const root = rec(stripProviderCost(payload));
104
+ const data = rec(root['data']);
105
+ // The tell is which naming the row came back in — B2C keeps the m_ prefix,
106
+ // B2B strips it. Checked on the DATA, which is where the difference lives.
107
+ const isB2bShape = data['m_id'] === undefined && data['id'] !== undefined;
108
+ const requestStatus = str(root['request_status']);
109
+ const rawStatus = isB2bShape ? str(data['status']) : str(data['m_status']);
110
+ const status = requestStatus ?? rawStatus;
111
+ const out = {
112
+ id: str(isB2bShape ? data['id'] : data['m_id']) ?? str(root['media_id']) ?? str(root['id']),
113
+ status,
114
+ done: status !== null && TERMINAL.has(status),
115
+ mode: str(isB2bShape ? data['mode'] : data['m_mode']),
116
+ model: str(isB2bShape ? data['model_name'] : data['m_name']) ??
117
+ str(isB2bShape ? data['model_key'] : data['m_model_key']),
118
+ credits_charged: int(isB2bShape ? data['credits_charged'] : data['m_coins']),
119
+ estimated_seconds: int(isB2bShape ? data['estimated_seconds'] : data['m_sec']),
120
+ /* Two different numbers, and the server names them apart: while a job
121
+ * is pending `elapsed_seconds` is time-since-submit, and once it is
122
+ * finished `actual_duration_seconds` is how long it actually took.
123
+ * Surface whichever exists, because an agent asking "how long" means
124
+ * the live one before and the final one after, never both.
125
+ *
126
+ * The result endpoint only ever answers about a finished job, so it
127
+ * carries the duration alone — reading `elapsed_seconds` there always
128
+ * yielded null, which is what get_result reported for every generation. */
129
+ elapsed_seconds: int(data['elapsed_seconds']) ?? int(data['actual_duration_seconds']),
130
+ created_at: str(isB2bShape ? data['created_at'] : data['m_time']),
131
+ /* B2B sends an ISO 8601 string under a clean name; B2C keeps the column
132
+ * name and its plain UTC datetime. Both are populated as of the same
133
+ * change that added them to the B2C block — before it, only the B2B
134
+ * door answered and every first-party caller saw null. */
135
+ completed_at: str(isB2bShape ? data['completed_at'] : data['m_completed_at']),
136
+ error: str(isB2bShape ? data['error_message'] : data['m_api_error']) ??
137
+ str(root['message'] === undefined ? null : (status !== null && TERMINAL.has(status) && status !== 'success' ? root['message'] : null)),
138
+ };
139
+ /* Gated on the B2B shape, not merely on the key being present.
140
+ *
141
+ * `cost_usd` means two different things on two sibling endpoints: on
142
+ * status/result it is the PARTNER's own price and theirs to see; on the
143
+ * pricing endpoint the same name is the PROVIDER cost, the one number this
144
+ * file exists to keep in. Emitting it whenever it appears made the
145
+ * distinction depend on which endpoint happened to answer — safe today only
146
+ * because the B2C branch does not emit the key, which is a fact about the
147
+ * server, not a property of this code. */
148
+ if (isB2bShape && data['cost_usd'] !== undefined)
149
+ out.cost_usd = int(data['cost_usd']);
150
+ return out;
151
+ }
152
+ export function mapResult(payload) {
153
+ const root = rec(stripProviderCost(payload));
154
+ const data = rec(root['data']);
155
+ const result = rec(data['result']);
156
+ /* Dropped, not overwritten — see the note on GenerationResult. Pulling it
157
+ out of the spread is what makes the key ABSENT rather than present-and-
158
+ null, which is the whole difference the reader notices. */
159
+ const { estimated_seconds: _estimateBelongsToStatusOnly, ...base } = mapStatus(payload);
160
+ const url = str(result['output_public_url']) ??
161
+ str(result['m_output_url']) ??
162
+ null;
163
+ return {
164
+ ...base,
165
+ media_type: str(data['media_type']) ?? str(data['m_media_type']),
166
+ url,
167
+ thumbnail_url: str(result['thumbnail_public_url']) ?? str(result['m_thumbnail']),
168
+ dimensions: str(result['output_dimension']) ?? str(result['m_output_dimension']),
169
+ // B2C only — the B2B response shape was deliberately left untouched,
170
+ // so a partner row simply reports null and skips the preview.
171
+ output_size: int(data['m_output_size']),
172
+ download_url: str(data['m_download_url']),
173
+ // === true, so a missing field (older server) or any non-boolean reads
174
+ // false — the card shows no upgrade button rather than a wrong one.
175
+ watermarked: data['m_watermarked'] === true,
176
+ /* Read off the URL, never off the configured mode.
177
+ *
178
+ * Which builder ran is a property of the ROW: the server signs the URL
179
+ * only when the row is first_party AND m_public !== 'on'; a public
180
+ * first-party row and every B2B row get a permanent CDN link
181
+ * instead. So one account-mode token legitimately receives
182
+ * both kinds, and deciding from cfg.mode gets it wrong in both
183
+ * directions — this is the same payload-vs-mode rule that governs
184
+ * mapStatus, applied to the one field that had escaped it.
185
+ *
186
+ * The signature is self-identifying: a presigned link carries
187
+ * X-Amz-Signature in its query string and expires in about 8 hours.
188
+ * No signature, no expiry. */
189
+ url_note: url === null
190
+ ? null
191
+ : /[?&]X-Amz-Signature=/i.test(url)
192
+ ? 'This link is signed and expires in about 8 hours. Call get_result again for a fresh one.'
193
+ : 'This link is a permanent public CDN URL — it does not expire.',
194
+ reuse_as_input: base.done && base.status === 'success' && base.id !== null
195
+ ? `To use this in another generation, pass {"from_generation": "${base.id}"} as the ` +
196
+ 'file input — do NOT download this link and upload it again.'
197
+ : null,
198
+ };
199
+ }
200
+ /* ── cost estimate ───────────────────────────────────────────────────────── */
201
+ export class CostUnavailableError extends Error {
202
+ }
203
+ /**
204
+ * Pull the number out of a model-credits response.
205
+ *
206
+ * `cost_credits` is NOT always a number. For a staff account the same endpoint
207
+ * returns an HTML debug string — "Price: <provider cost> USD <br> Coins: <n>" —
208
+ * because the handler enriches it internally. An agent that forwarded that to a user
209
+ * would be quoting HTML as a price, and one that did arithmetic on it would get
210
+ * NaN and silently charge whatever the submit costs.
211
+ *
212
+ * @param unit What the number means in this mode, for the message.
213
+ */
214
+ export function readCostCredits(payload, unit) {
215
+ const root = rec(payload);
216
+ const raw = root['cost_credits'];
217
+ if (typeof raw === 'number' && Number.isFinite(raw))
218
+ return raw;
219
+ // A numeric string is fine — some paths JSON-encode it that way.
220
+ if (typeof raw === 'string' && raw.trim() !== '' && Number.isFinite(Number(raw))) {
221
+ return Number(raw);
222
+ }
223
+ if (typeof raw === 'string') {
224
+ /* The payload is deliberately NOT quoted here. On a staff account the
225
+ pricing endpoint builds cost_credits as
226
+ "Price: <provider cost> USD <br> Coins: <n>", so echoing it would
227
+ carry the provider cost into an error message that index.ts hands
228
+ straight to the model — the one number this package exists to keep
229
+ in-house, leaking through the guard that was written to catch it. */
230
+ throw new CostUnavailableError('Vidofy returned a price in a debug format instead of a number. ' +
231
+ 'This happens on staff accounts. Do not quote a price to the user — ' +
232
+ 'generate will still charge the correct amount.');
233
+ }
234
+ throw new CostUnavailableError(`Vidofy did not return a price in ${unit}. The model may be unavailable, or a required input is missing.`);
235
+ }
236
+ //# sourceMappingURL=b2c.js.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * How long is this media file, read from its own header.
3
+ *
4
+ * WHY THIS EXISTS. Nine active models cap the LENGTH of what you upload —
5
+ * lipsync and motion-control mostly, 30s of video or 30-600s of audio. The
6
+ * server enforces it and says so clearly ("Video file too long (45s). Max
7
+ * allowed per video file: 30s."), before anything is charged. What it cannot
8
+ * do is refund the upload: a 200 MB clip travels in full to be told it is
9
+ * fifteen seconds too long. The studio avoids that in the browser, by asking
10
+ * a <video> element for its duration. Node has no such thing, so this reads
11
+ * the container.
12
+ *
13
+ * WHY IT RETURNS null RATHER THAN A GUESS. The caller REFUSES a file on this
14
+ * number, and a wrong number refuses a legitimate upload — which is worse
15
+ * than the wasted bandwidth it was meant to save. So every format whose
16
+ * duration is not exactly recoverable from the header answers null, and the
17
+ * caller then does what it did before: send it, and let the server decide.
18
+ *
19
+ * WHAT THAT COVERS, measured against the live catalogue rather than guessed:
20
+ * the capped slots accept exactly four extensions — mp4, mov, mp3, wav.
21
+ *
22
+ * mp4 / mov ISO-BMFF, both of them. mvhd carries duration and timescale.
23
+ * EXACT. This is also where the saving is: video files are the
24
+ * large ones.
25
+ * wav RIFF. data chunk size ÷ byte rate. EXACT.
26
+ * mp3 null. There is no duration in an MP3 header — it would have
27
+ * to be inferred from a Xing/VBRI tag or by counting frames,
28
+ * and an inferred number must not drive a refusal. A 30s MP3 is
29
+ * under a megabyte anyway, so the upload it would have saved is
30
+ * not worth the risk of rejecting a valid one.
31
+ */
32
+ /** Seconds, or null when this file's format does not state it exactly. */
33
+ export declare function durationSecFromBuffer(buf: Buffer, extension: string): number | null;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * How long is this media file, read from its own header.
3
+ *
4
+ * WHY THIS EXISTS. Nine active models cap the LENGTH of what you upload —
5
+ * lipsync and motion-control mostly, 30s of video or 30-600s of audio. The
6
+ * server enforces it and says so clearly ("Video file too long (45s). Max
7
+ * allowed per video file: 30s."), before anything is charged. What it cannot
8
+ * do is refund the upload: a 200 MB clip travels in full to be told it is
9
+ * fifteen seconds too long. The studio avoids that in the browser, by asking
10
+ * a <video> element for its duration. Node has no such thing, so this reads
11
+ * the container.
12
+ *
13
+ * WHY IT RETURNS null RATHER THAN A GUESS. The caller REFUSES a file on this
14
+ * number, and a wrong number refuses a legitimate upload — which is worse
15
+ * than the wasted bandwidth it was meant to save. So every format whose
16
+ * duration is not exactly recoverable from the header answers null, and the
17
+ * caller then does what it did before: send it, and let the server decide.
18
+ *
19
+ * WHAT THAT COVERS, measured against the live catalogue rather than guessed:
20
+ * the capped slots accept exactly four extensions — mp4, mov, mp3, wav.
21
+ *
22
+ * mp4 / mov ISO-BMFF, both of them. mvhd carries duration and timescale.
23
+ * EXACT. This is also where the saving is: video files are the
24
+ * large ones.
25
+ * wav RIFF. data chunk size ÷ byte rate. EXACT.
26
+ * mp3 null. There is no duration in an MP3 header — it would have
27
+ * to be inferred from a Xing/VBRI tag or by counting frames,
28
+ * and an inferred number must not drive a refusal. A 30s MP3 is
29
+ * under a megabyte anyway, so the upload it would have saved is
30
+ * not worth the risk of rejecting a valid one.
31
+ */
32
+ /** Seconds, or null when this file's format does not state it exactly. */
33
+ export function durationSecFromBuffer(buf, extension) {
34
+ const ext = extension.trim().toLowerCase().replace(/^\./, '');
35
+ if (ext === 'mp4' || ext === 'mov' || ext === 'm4a' || ext === 'm4v') {
36
+ return isoBmffDuration(buf);
37
+ }
38
+ if (ext === 'wav') {
39
+ return wavDuration(buf);
40
+ }
41
+ return null;
42
+ }
43
+ /* ── ISO base media file format (mp4, mov) ───────────────────────────────── */
44
+ /**
45
+ * Walk the top-level boxes to `moov`, then its children to `mvhd`.
46
+ *
47
+ * Walked rather than searched: `moov` may sit at the START of the file
48
+ * (faststart) or at the END, and scanning the bytes for the literal "mvhd"
49
+ * would also match it inside any payload that happens to contain those four
50
+ * characters — media data included.
51
+ */
52
+ function isoBmffDuration(buf) {
53
+ const moov = findBox(buf, 0, buf.length, 'moov');
54
+ if (!moov)
55
+ return null;
56
+ const mvhd = findBox(buf, moov.contentStart, moov.end, 'mvhd');
57
+ if (!mvhd)
58
+ return null;
59
+ const p = mvhd.contentStart; // payload: version, flags, …
60
+ if (p + 4 > buf.length)
61
+ return null;
62
+ const version = buf[p];
63
+ // v0 packs the four fields as 32-bit; v1 widens creation/modification to
64
+ // 64 and duration to 64, leaving timescale 32.
65
+ const tsOff = version === 1 ? p + 20 : p + 12;
66
+ const durOff = version === 1 ? p + 24 : p + 16;
67
+ const durLen = version === 1 ? 8 : 4;
68
+ if (durOff + durLen > buf.length)
69
+ return null;
70
+ const timescale = buf.readUInt32BE(tsOff);
71
+ if (timescale === 0)
72
+ return null; // would divide by zero
73
+ const duration = version === 1
74
+ ? Number(buf.readBigUInt64BE(durOff))
75
+ : buf.readUInt32BE(durOff);
76
+ // 0xFFFFFFFF is the documented "unknown duration" marker.
77
+ if (!Number.isFinite(duration) || duration <= 0 || duration === 0xffffffff)
78
+ return null;
79
+ return duration / timescale;
80
+ }
81
+ /** The first child box of `type` between [from, limit). */
82
+ function findBox(buf, from, limit, type) {
83
+ let off = from;
84
+ while (off + 8 <= limit) {
85
+ let size = buf.readUInt32BE(off);
86
+ const boxType = buf.toString('latin1', off + 4, off + 8);
87
+ let header = 8;
88
+ if (size === 1) {
89
+ // 64-bit size, in the eight bytes after the type.
90
+ if (off + 16 > limit)
91
+ return null;
92
+ size = Number(buf.readBigUInt64BE(off + 8));
93
+ header = 16;
94
+ }
95
+ else if (size === 0) {
96
+ size = limit - off; // "to the end of the file"
97
+ }
98
+ // A size smaller than its own header, or past the limit, means the
99
+ // file is malformed or truncated — stop rather than walk off.
100
+ if (size < header || off + size > limit)
101
+ return null;
102
+ if (boxType === type)
103
+ return { contentStart: off + header, end: off + size };
104
+ off += size;
105
+ }
106
+ return null;
107
+ }
108
+ /* ── RIFF / WAVE ─────────────────────────────────────────────────────────── */
109
+ function wavDuration(buf) {
110
+ if (buf.length < 12)
111
+ return null;
112
+ if (buf.toString('latin1', 0, 4) !== 'RIFF' || buf.toString('latin1', 8, 12) !== 'WAVE') {
113
+ return null;
114
+ }
115
+ let byteRate = 0;
116
+ let dataSize = 0;
117
+ let off = 12;
118
+ while (off + 8 <= buf.length) {
119
+ const id = buf.toString('latin1', off, off + 4);
120
+ const size = buf.readUInt32LE(off + 4);
121
+ const body = off + 8;
122
+ if (size < 0 || body + size > buf.length + 1)
123
+ return null; // truncated
124
+ if (id === 'fmt ' && size >= 16 && body + 16 <= buf.length) {
125
+ byteRate = buf.readUInt32LE(body + 8);
126
+ }
127
+ else if (id === 'data') {
128
+ dataSize = size;
129
+ }
130
+ // Chunks are word-aligned: an odd size is followed by a pad byte.
131
+ off = body + size + (size % 2);
132
+ }
133
+ if (byteRate <= 0 || dataSize <= 0)
134
+ return null;
135
+ return dataSize / byteRate;
136
+ }
137
+ //# sourceMappingURL=media-duration.js.map
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `GET /mcp-app/authorize` — the protocol half of the authorization endpoint.
3
+ *
4
+ * It does everything that does not need to know who the user is: identify the
5
+ * calling client, check what it asked for, park the request, and hand the
6
+ * browser to the consent screen. Deciding is the user's half, and that lives at
7
+ * /en/oauth/consent on the site itself, because the session cookie is
8
+ * host-scoped to vidofy.ai by the site's session layer, and this process
9
+ * cannot read it.
10
+ *
11
+ * TWO URLS, ONE FLOW — and they are named differently on purpose:
12
+ *
13
+ * /mcp-app/authorize machine-facing. What `authorization_endpoint` advertises.
14
+ * /en/oauth/consent human-facing. Product-neutral, so the CLI or any later
15
+ * client reaches the same screen (owner, 2026-09-12).
16
+ *
17
+ * Calling both "authorize" was the first draft and would have cost somebody an
18
+ * hour six months from now.
19
+ *
20
+ * THE ERROR RULE THAT MATTERS
21
+ * ---------------------------
22
+ * OAuth 2.1 splits failures in two, and the split is a security boundary rather
23
+ * than a style:
24
+ *
25
+ * • client_id or redirect_uri is bad → answer HERE, never redirect. Redirecting
26
+ * to a URI we have not validated IS the open redirect.
27
+ * • anything else is wrong → redirect to the VALIDATED redirect_uri
28
+ * with `error` and `state`, because the client is waiting there and a page
29
+ * served by us is a dead end it cannot recover from.
30
+ */
31
+ import type { ServerResponse } from 'node:http';
32
+ /**
33
+ * @param canonicalResource This server's own resource identifier — the value
34
+ * both hosts send as `resource`. A request naming anything else is asking
35
+ * us to mint a token for an audience we do not serve.
36
+ */
37
+ export declare function handleAuthorize(res: ServerResponse, url: URL, canonicalResource: string, opts?: {
38
+ allowPrivateClients?: boolean;
39
+ }): Promise<void>;
40
+ /**
41
+ * `GET /mcp-app/authorize/decide?request=<id>` — the browser coming back from
42
+ * the consent page.
43
+ *
44
+ * Turns a recorded decision into the OAuth response the waiting client expects:
45
+ * a code, or `access_denied`.
46
+ *
47
+ * WHAT IT TRUSTS, AND WHY
48
+ * -----------------------
49
+ * Everything comes from the stored record; nothing from this request except the
50
+ * id. In particular the redirect_uri is the one /authorize validated against the
51
+ * client's metadata document minutes ago — reading it from the query here would
52
+ * undo that check and hand anyone with a request id a code sent wherever they
53
+ * like.
54
+ *
55
+ * The approving user's identity comes from the record too, written by the
56
+ * site's consent page after it authenticated the session. This process
57
+ * cannot verify a session cookie (host-scoped to the site), so the trust
58
+ * boundary is Redis itself: only the site and this connector can write those
59
+ * keys, and Redis is not reachable from outside the host.
60
+ */
61
+ export declare function handleAuthorizeDecide(res: ServerResponse, url: URL): Promise<void>;