/** * Calls the API on the model's behalf, and keeps it signed in. * * Tokens come from the Pireon identity server through POST /api/v1/auth/token * and expire, so a fixed bearer token in the MCP config would die mid-session. * With credentials instead, this client logs in on the first authenticated * call, refreshes before expiry, and retries once on a 401 — a token revoked or * expired upstream costs one extra round trip, not a broken session. */ import { readFile, stat } from 'node:fs/promises'; import { basename } from 'node:path'; // Refresh this long before the stated expiry, so a token never expires between // the check and the request. const EXPIRY_MARGIN_MS = 60_000; const TIMEOUT_MS = 30_000; // Above the API's own 20 MB so its answer, not this one, is what a caller // usually sees; this only stops something absurd being read into memory. const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; export class LandingClient { /** * @param {object} options * @param {string} options.apiBase e.g. https://admin.example.com/api * @param {string} [options.token] A fixed bearer token. * @param {string} [options.username] * @param {string} [options.password] * @param {string} [options.previewToken] Sent on public calls to see drafts. * @param {typeof fetch} [options.fetch] */ constructor({ apiBase, token, username, password, previewToken, fetch = globalThis.fetch }) { this.apiBase = apiBase.replace(/\/+$/, ''); this.fixedToken = token || null; this.credentials = username && password ? { username, password } : null; this.previewToken = previewToken || null; this.fetch = fetch; /** @type {{access: string, refresh: string|null, expiresAt: number|null}|null} */ this.session = null; this.pending = null; } canAuthenticate() { return Boolean(this.fixedToken || this.credentials); } /** * Proves the credentials or token work, by spending them on GET /v1/me. * Throws for credentials the application refused; resolves the response * otherwise, for the caller to judge its status. */ async verify() { return this.call({ method: 'GET', path: '/v1/me', params: [], hasBody: false, requiresAuth: true }); } /** * @param {import('./tools.mjs').Operation} operation * @param {Record} args * @returns {Promise<{status: number, body: unknown}>} */ async call(operation, args = {}) { const request = await this.buildRequest(operation, args); if (!operation.requiresAuth) { if (this.previewToken) request.headers['X-Preview-Token'] = this.previewToken; return this.send(request); } request.headers.Authorization = `Bearer ${await this.accessToken()}`; let response = await this.send(request); // A fixed token has nothing to renew itself with, so its 401 stands. if (response.status === 401 && !this.fixedToken) { this.session = { ...this.session, access: null }; request.headers.Authorization = `Bearer ${await this.accessToken()}`; response = await this.send(request); } return response; } async buildRequest(operation, args) { let path = operation.path; const query = new URLSearchParams(); for (const { name, in: location } of operation.params) { const value = args[name]; if (location === 'path') { if (value === undefined || value === null || value === '') { throw new Error(`Missing path parameter "${name}"`); } path = path.replace(`{${name}}`, encodeURIComponent(String(value))); } else if (value !== undefined && value !== null) { // Laravel's boolean() and the `boolean` rule both accept 1/0; only the // former accepts "true". query.set(name, typeof value === 'boolean' ? (value ? '1' : '0') : String(value)); } } const headers = { Accept: 'application/json' }; let body; const uploads = (operation.files ?? []).filter((field) => args[`${field}_path`]); if (uploads.length > 0) { // No Content-Type: fetch writes it, boundary included, from the form. body = new FormData(); for (const [key, value] of Object.entries(args.body ?? {})) { body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value)); } for (const field of uploads) { body.append(field, ...(await this.readUpload(args[`${field}_path`]))); } } else if (operation.hasBody && args.body !== undefined) { headers['Content-Type'] = 'application/json'; body = JSON.stringify(args.body); } const qs = query.toString(); return { url: `${this.apiBase}${path}${qs ? `?${qs}` : ''}`, method: operation.method, headers, body, }; } /** @returns {Promise<[Blob, string]>} */ async readUpload(path) { let info; try { info = await stat(path); } catch { throw new Error(`Cannot read ${path}: no such file`); } if (!info.isFile()) throw new Error(`${path} is not a file`); if (info.size > MAX_UPLOAD_BYTES) throw new Error(`${path} is ${Math.round(info.size / 1048576)} MB; the limit is 20 MB`); return [new Blob([await readFile(path)]), basename(path)]; } async send({ url, method, headers, body }) { const response = await this.fetch(url, { method, headers, body, signal: AbortSignal.timeout(TIMEOUT_MS), }); const text = await response.text(); let parsed = text; try { parsed = text ? JSON.parse(text) : null; } catch { // Not JSON (an HTML error page from a proxy, say): hand it over as text. } return { status: response.status, body: parsed }; } async accessToken() { if (this.fixedToken) return this.fixedToken; if (!this.credentials) { throw new Error('This endpoint needs a token: set LANDING_TOKEN, or LANDING_USERNAME and LANDING_PASSWORD.'); } const session = this.session; const fresh = session?.access && (session.expiresAt === null || session.expiresAt - Date.now() > EXPIRY_MARGIN_MS); if (fresh) return session.access; // Parallel tool calls would otherwise each log in, and the token endpoint // allows six requests a minute. this.pending ??= this.renew().finally(() => { this.pending = null; }); return (await this.pending).access; } async renew() { if (this.session?.refresh) { try { return await this.exchange({ grant_type: 'refresh_token', refresh_token: this.session.refresh }); } catch { // Refresh tokens expire and get revoked too; the password still works. } } return this.exchange({ grant_type: 'password', ...this.credentials }); } async exchange(grant) { const { status, body } = await this.send({ url: `${this.apiBase}/v1/auth/token`, method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify(grant), }); if (status !== 200 || !body?.access_token) { const reason = body?.message ?? (typeof body === 'string' ? body.slice(0, 200) : `HTTP ${status}`); const error = new Error(`Could not obtain a token (${grant.grant_type} grant): ${reason}`); // 401/403/422 are the application's answer about these credentials; // anything else says nothing about them. The HTTP transport needs to // tell the two apart. error.status = status; throw error; } this.session = { access: body.access_token, refresh: body.refresh_token ?? null, expiresAt: body.expires_at ? Date.parse(body.expires_at) : null, }; return this.session; } }