@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,165 @@
1
+ /**
2
+ * `POST /mcp-app/token` — the authorization code becomes an access token.
3
+ *
4
+ * This is the only place a credential leaves the server, so everything it checks
5
+ * is a precondition for handing one over:
6
+ *
7
+ * 1. the code exists and has not been used (GETDEL — one code, one token)
8
+ * 2. the client asking is the client the code was issued to
9
+ * 3. the redirect_uri matches the one bound to the code
10
+ * 4. PKCE: SHA256(code_verifier) equals the challenge recorded at /authorize
11
+ *
12
+ * PKCE IS THE WHOLE PROTECTION HERE
13
+ * ---------------------------------
14
+ * The code travels through the user's browser — in a URL, through history, past
15
+ * whatever extensions are installed. Anyone who captures it could exchange it,
16
+ * and the client authenticates with `none` (a public client has no secret to
17
+ * prove itself with). What stops the exchange is that the thief does not have
18
+ * the verifier: it never left the client. So this check is not defence in depth,
19
+ * it is the door.
20
+ *
21
+ * WHY THERE IS NO TOKEN GENERATION IN THIS FILE
22
+ * ---------------------------------------------
23
+ * The `vmt_` token was minted by the site when the user approved, and rides in
24
+ * the code record. The site owns the credential format, the hash, the scope and
25
+ * the expiry; a second implementation here would be a second definition of one
26
+ * credential.
27
+ */
28
+ import { createHash, timingSafeEqual } from 'node:crypto';
29
+ import { consumeCode } from './store.js';
30
+ /** Read a urlencoded body, capped — a token request is a few hundred bytes. */
31
+ async function readForm(req) {
32
+ const MAX = 8 * 1024;
33
+ const chunks = [];
34
+ let size = 0;
35
+ for await (const chunk of req) {
36
+ const b = chunk;
37
+ size += b.length;
38
+ if (size > MAX)
39
+ throw new Error('body too large');
40
+ chunks.push(b);
41
+ }
42
+ return new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
43
+ }
44
+ function fail(res, status, error, description) {
45
+ /* RFC 6749 §5.2: the token endpoint's errors are JSON with no-store, and the
46
+ status matters — a client distinguishes "your code is bad" (400) from
47
+ "come back later". */
48
+ res.writeHead(status, {
49
+ 'content-type': 'application/json; charset=utf-8',
50
+ 'cache-control': 'no-store',
51
+ pragma: 'no-cache',
52
+ });
53
+ res.end(JSON.stringify({ error, error_description: description }));
54
+ }
55
+ /** Constant-time compare of two base64url strings, safe on length mismatch. */
56
+ function sameSecret(a, b) {
57
+ const ab = Buffer.from(a, 'utf8');
58
+ const bb = Buffer.from(b, 'utf8');
59
+ /* timingSafeEqual throws when the lengths differ, and returning early on
60
+ that is fine: the length of a PKCE challenge is not the secret. */
61
+ if (ab.length !== bb.length)
62
+ return false;
63
+ return timingSafeEqual(ab, bb);
64
+ }
65
+ export async function handleToken(req, res) {
66
+ if (req.method !== 'POST') {
67
+ fail(res, 405, 'invalid_request', 'The token endpoint accepts POST.');
68
+ return;
69
+ }
70
+ let form;
71
+ try {
72
+ form = await readForm(req);
73
+ }
74
+ catch {
75
+ fail(res, 400, 'invalid_request', 'Request body could not be read.');
76
+ return;
77
+ }
78
+ const get = (k) => (form.get(k) ?? '').trim();
79
+ if (get('grant_type') !== 'authorization_code') {
80
+ /* The RFC's own code for this, and the reason we advertise only
81
+ authorization_code: there is no refresh token to grant. */
82
+ fail(res, 400, 'unsupported_grant_type', 'Only grant_type=authorization_code is supported.');
83
+ return;
84
+ }
85
+ const code = get('code');
86
+ const verifier = get('code_verifier');
87
+ const clientId = get('client_id');
88
+ const redirectUri = get('redirect_uri');
89
+ if (code === '' || verifier === '') {
90
+ fail(res, 400, 'invalid_request', 'code and code_verifier are required.');
91
+ return;
92
+ }
93
+ /* Consumed before anything is validated, deliberately.
94
+ *
95
+ * A code that fails any check below is burned either way — OAuth 2.1 says a
96
+ * code must not be reusable, and a failed attempt is the clearest sign it
97
+ * may have been stolen. Validating first and deleting after would leave a
98
+ * stolen code alive for as many guesses as the attacker wants. */
99
+ const issued = await consumeCode(code);
100
+ if (issued === null) {
101
+ fail(res, 400, 'invalid_grant', 'The authorization code is invalid or expired.');
102
+ return;
103
+ }
104
+ /* The client must be the one the code belongs to, and the redirect_uri must be
105
+ * the one the code was bound to. Since these are public clients with no
106
+ * secret, client_id is all there is to compare.
107
+ *
108
+ * ⚠ BOTH CHECKS USED TO BE OPTIONAL, and the comments that stood here stated
109
+ * the requirement while the code declined to enforce it: they read
110
+ * `clientId !== '' && clientId !== issued.clientId`, so a token request that
111
+ * simply OMITTED client_id and redirect_uri skipped both. RFC 6749 §4.1.3
112
+ * makes client_id REQUIRED for a public client precisely so a code cannot be
113
+ * redeemed by whoever holds it.
114
+ *
115
+ * That permissive branch served no case anyone could name — the measured
116
+ * clients (claude.ai and ChatGPT) both send client_id, and the project's rule
117
+ * is that a lenient branch must prove the case it exists for or fail closed.
118
+ * It now fails closed. */
119
+ if (clientId === '') {
120
+ fail(res, 400, 'invalid_request', 'client_id is required.');
121
+ return;
122
+ }
123
+ if (clientId !== issued.clientId) {
124
+ fail(res, 400, 'invalid_grant', 'The code was not issued to this client.');
125
+ return;
126
+ }
127
+ if (redirectUri === '') {
128
+ fail(res, 400, 'invalid_request', 'redirect_uri is required.');
129
+ return;
130
+ }
131
+ if (redirectUri !== issued.redirectUri) {
132
+ fail(res, 400, 'invalid_grant', 'redirect_uri does not match the authorization request.');
133
+ return;
134
+ }
135
+ /* PKCE S256: BASE64URL(SHA256(ASCII(verifier))) must equal the challenge. */
136
+ const computed = createHash('sha256').update(verifier, 'ascii').digest('base64url');
137
+ if (!sameSecret(computed, issued.codeChallenge)) {
138
+ fail(res, 400, 'invalid_grant', 'code_verifier does not match the challenge.');
139
+ return;
140
+ }
141
+ /* ── Everything checked. Hand over the token. ────────────────────────────
142
+ *
143
+ * No `refresh_token`, by decision and with the spec's blessing: "MCP Clients
144
+ * MUST NOT assume refresh tokens will be issued; the AS retains discretion".
145
+ * The access token is long-lived instead, and `expires_in` says so honestly
146
+ * rather than implying forever.
147
+ *
148
+ * One year, matching the expiry the site stamped on the row. The two are
149
+ * written in two places and that is a drift risk worth naming: if the
150
+ * site's '+1 year' changes, this number must change with it. */
151
+ const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
152
+ res.writeHead(200, {
153
+ 'content-type': 'application/json; charset=utf-8',
154
+ // Required by RFC 6749 §5.1 — a cached token response is a leaked token.
155
+ 'cache-control': 'no-store',
156
+ pragma: 'no-cache',
157
+ });
158
+ res.end(JSON.stringify({
159
+ access_token: issued.rawToken,
160
+ token_type: 'Bearer',
161
+ expires_in: ONE_YEAR_SECONDS,
162
+ scope: issued.scope,
163
+ }));
164
+ }
165
+ //# sourceMappingURL=token.js.map
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Turn one model's admin-authored `m_options` into a JSON Schema an agent can
3
+ * fill in, plus the wire names needed to actually submit it.
4
+ *
5
+ * This is the widest surface in the package. A human wrote the options for every
6
+ * model in the catalogue, so the rule here is: describe only what the data
7
+ * actually contains, and stay silent rather than invent. A property this file
8
+ * omits costs the agent a feature; a property it invents costs the user a
9
+ * rejected generation they already waited for.
10
+ *
11
+ * SHAPES, MEASURED ACROSS THE WHOLE ACTIVE CATALOGUE — not read off the docs:
12
+ * m_aspect_ratio ARRAY all (often empty)
13
+ * m_resolution_quality ARRAY all (often empty)
14
+ * m_duration OBJECT some KEYS are the durations; each value is
15
+ * the list of resolutions that duration
16
+ * allows ({"5": [], "10": []} = any)
17
+ * m_output_number INTEGER all
18
+ * m_dynamic_fields ARRAY all a long tail of field names
19
+ * m_multi_upload OBJECT all 8 key sets, TWO families (see below)
20
+ * m_negative_prompt / m_seed / m_generate_audio /
21
+ * m_camera_fixed / m_enhance_prompt all BOOLEAN
22
+ *
23
+ * DYNAMIC FIELD TYPES IN USE — only five, though the docs list nine:
24
+ * radio_group · select · toggle · slider · file_upload_image
25
+ * Unknown types degrade to a string rather than throwing: a model added
26
+ * tomorrow must not break the whole catalogue.
27
+ */
28
+ /** Minimal JSON Schema draft-07 subset — everything this file emits. */
29
+ export interface JsonSchemaProperty {
30
+ type: 'string' | 'number' | 'integer' | 'boolean';
31
+ description?: string;
32
+ enum?: Array<string | number>;
33
+ default?: string | number | boolean;
34
+ minimum?: number;
35
+ maximum?: number;
36
+ multipleOf?: number;
37
+ }
38
+ export interface FileInput {
39
+ /** The name the agent uses. */
40
+ name: string;
41
+ /** The multipart field the server reads. */
42
+ wire: string;
43
+ required: boolean;
44
+ accepts: string[];
45
+ maxSizeMb: number | null;
46
+ maxDurationSec: number | null;
47
+ description: string;
48
+ /**
49
+ * Whether this slot accepts `{"from_generation": "<id>"}` instead of a path.
50
+ *
51
+ * Not a property of the slot but of the SERVER: reuse travels in
52
+ * regen_source_map, and the submit path keeps a whitelist of field names it
53
+ * will honour — m_image, m_video,
54
+ * m_audio, m_first_frame, m_last_frame, m_multi_file_N, and any dynamic
55
+ * file field by its own name. A name outside it is dropped in silence, so
56
+ * the job runs with no input and 422s on a slot the agent believes it
57
+ * filled. The per-type multi slots (m_multi_<type>_file_N) are the ones
58
+ * that fall outside, and get_model now says so instead of leaving it to be
59
+ * discovered by spending credits.
60
+ */
61
+ supportsReuse: boolean;
62
+ }
63
+ export interface ModelSchema {
64
+ model_key: string;
65
+ /**
66
+ * The scene identifier for an effect model, '' for everything else.
67
+ *
68
+ * A large share of the catalogue are effect models, and this is what tells
69
+ * the price handler and the worker WHICH effect. Sent on the wire, never
70
+ * shown as an agent-facing input: it is a property of the model the agent
71
+ * already chose, not a decision it makes.
72
+ */
73
+ effect_key: string;
74
+ slug: string;
75
+ name: string;
76
+ /**
77
+ * Absolute URL of the provider's logo, or '' when the row has none.
78
+ *
79
+ * Served from the site's own origin, not the CDN — the server prefixes the
80
+ * stored relative path with its own origin before returning it. Every
81
+ * active model carries one (measured), so a card can label a generation
82
+ * with the mark of whoever made it instead of a coloured placeholder.
83
+ */
84
+ icon: string;
85
+ /** Short code (t2v) — what the agent sees and what list_models accepts. */
86
+ mode: string;
87
+ /** Long key (text-to-video) — what m_mode means on the wire. Not for display. */
88
+ mode_wire: string;
89
+ media_type: string;
90
+ /**
91
+ * FLOOR, not price: the cheapest this model can cost across every option
92
+ * combination — the "from X credits" badge the website shows on its model
93
+ * picker. It is the model row's `m_coins`, which the server computes by
94
+ * pricing the whole option grid and taking the minimum.
95
+ *
96
+ * The real price of a specific request is routinely a MULTIPLE of it — a
97
+ * high resolution on the same model can cost several times the floor.
98
+ * Named `_from` because the bare word
99
+ * `credits` reads as "the price", and an agent that reports it as one
100
+ * quotes the user a number they will not be charged.
101
+ *
102
+ * Only estimate_cost answers what a given input costs.
103
+ */
104
+ credits_from: number | null;
105
+ estimated_seconds: number | null;
106
+ inputSchema: {
107
+ type: 'object';
108
+ properties: Record<string, JsonSchemaProperty>;
109
+ required: string[];
110
+ additionalProperties: false;
111
+ };
112
+ /** clean name → the m_* form field the server actually reads. */
113
+ wire: Record<string, string>;
114
+ files: FileInput[];
115
+ /**
116
+ * This model is billed by the DURATION OF THE MEDIA THE USER UPLOADS, not
117
+ * by the settings alone.
118
+ *
119
+ * It matters because estimate_cost never sends the file — the pricing
120
+ * endpoint receives no upload and would discard one — so for these models
121
+ * the quote covers the settings and the real charge is higher. Saying so
122
+ * is the difference between a price and a number.
123
+ *
124
+ * Read from m_pricing_mode, which the server DERIVES for this purpose: the
125
+ * model-info endpoint runs the same pricing resolver the
126
+ * submit path uses for its ffprobe gate, and normalises the answer into
127
+ * m_pricing_mode='per_second' whether it came from m_pricing_rules or the
128
+ * legacy m_options gate. So one field covers both, and it cannot drift
129
+ * from what is actually charged.
130
+ *
131
+ * Measured over the whole active catalogue: the field a client sees agrees
132
+ * with the resolver everywhere. A minority are upload-billed, and some of
133
+ * those are invisible in the raw m_options — reading the raw option blob
134
+ * instead of the served field would have been silently wrong for them.
135
+ */
136
+ billedByUploadDuration: boolean;
137
+ /** Things the agent must know that a schema cannot express. */
138
+ notes: string[];
139
+ }
140
+ /**
141
+ * @param payload The whole `/info/model-info/{slug}` body. The B2C door wraps
142
+ * the model under `.model` with m_-prefixed keys; the B2B door returns the
143
+ * options flat and unprefixed. Both are accepted, and which one arrived is
144
+ * decided by the PAYLOAD, never by the configured mode — a status lookup can
145
+ * legitimately return the other shape.
146
+ */
147
+ export declare function buildModelSchema(payload: unknown): ModelSchema;