UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

348 lines • 14.3 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { chunks } from "../../io/cooperative.js"; import { Sha1 } from "../../utils/hash.js"; import { encodeBase64 } from "../../utils/base64.js"; import { rstripSlash } from "../../utils/slash.js"; import {} from "../../utils/ranges.js"; import { callerSignal } from "../../context/session_context.js"; import { apiRequest, stallTimeout } from "../api/client.js"; import { DEFAULT_TIMEOUT } from "../api/constants.js"; import { TokenManager as OAuthTokenManager } from "../api/oauth.js"; import { BOX_API_BASE, BOX_TOKEN_URL, BOX_UPLOAD_BASE, TOKEN_BUFFER_SECONDS } from "./constants.js"; import { ApiError } from "../api/errors.js"; function tokenUrlOf(config) { return config.endpoint !== undefined && config.endpoint !== '' ? `${rstripSlash(config.endpoint)}/oauth2/token` : BOX_TOKEN_URL; } function apiBaseOf(config) { return config.endpoint !== undefined && config.endpoint !== '' ? `${rstripSlash(config.endpoint)}/2.0` : BOX_API_BASE; } function uploadBaseOf(config) { return config.endpoint !== undefined && config.endpoint !== '' ? apiBaseOf(config) : BOX_UPLOAD_BASE; } export class BoxApiError extends ApiError { conflict; constructor(message, status, conflict = null) { super(message, status); this.conflict = conflict; this.name = 'BoxApiError'; } } /** * The type of the item a 409 names in `context_info.conflicts`, or null. * Mirrors Python's `conflict_of`. */ export function conflictOf(text) { if (!text.trimStart().startsWith('{')) return null; let body; try { body = JSON.parse(text); } catch (err) { console.debug(`Box error body is not JSON: ${String(err)}`); return null; } const info = body?.context_info; const conflicts = info?.conflicts; const first = Array.isArray(conflicts) ? conflicts[0] : conflicts; const kind = first?.type; return typeof kind === 'string' ? kind : null; } /** The error a Box call answers with. Mirrors Python's `_error_of`. */ function errorOf(label, url) { return (r, text) => new BoxApiError(`Box ${label} ${url} → ${String(r.status)} ${text}`, r.status, conflictOf(text)); } async function refreshAccessToken(config, currentRefreshToken) { if (config.clientId === undefined || config.clientId === '') { throw new BoxApiError('refreshAccessToken: clientId is required', 400); } const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: currentRefreshToken, client_id: config.clientId, }); if (config.clientSecret !== undefined && config.clientSecret !== '') { body.set('client_secret', config.clientSecret); } const data = (await apiRequest('POST', tokenUrlOf(config), { timeout: config.timeout, errorOf: (r, text) => new BoxApiError(`Box token refresh → ${String(r.status)} ${text}`, r.status), headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), })); return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresIn: data.expires_in, }; } async function fetchCcgToken(config) { if (config.clientId === undefined || config.clientId === '') { throw new BoxApiError('fetchCcgToken: clientId is required', 400); } if (config.clientSecret === undefined || config.clientSecret === '') { throw new BoxApiError('fetchCcgToken: clientSecret is required', 400); } const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: config.clientId, client_secret: config.clientSecret, box_subject_type: 'enterprise', box_subject_id: config.enterpriseId ?? '', }); const data = (await apiRequest('POST', tokenUrlOf(config), { timeout: config.timeout, errorOf: (r, text) => new BoxApiError(`Box CCG token → ${String(r.status)} ${text}`, r.status), headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), })); return { accessToken: data.access_token, expiresIn: data.expires_in }; } export class BoxTokenManager extends OAuthTokenManager { apiBase; uploadBase; config; devTokenMode; ccgMode; currentRefreshToken; constructor(config) { super(TOKEN_BUFFER_SECONDS, undefined, config.timeout); this.config = config; // API and upload bases for every non-token call; api.ts reads these instead // of the constants so a config endpoint override reaches every request. this.apiBase = apiBaseOf(config); this.uploadBase = uploadBaseOf(config); this.devTokenMode = config.accessToken !== undefined && config.accessToken !== ''; this.ccgMode = !this.devTokenMode && config.enterpriseId !== undefined && config.enterpriseId !== ''; if (this.ccgMode) { if (config.clientId === undefined || config.clientId === '') { throw new Error('BoxTokenManager: clientId is required when using enterpriseId'); } if (config.clientSecret === undefined || config.clientSecret === '') { throw new Error('BoxTokenManager: clientSecret is required when using enterpriseId'); } } else if (!this.devTokenMode) { if (config.refreshToken === undefined || config.refreshToken === '') { throw new Error('BoxTokenManager: provide accessToken (developer token), clientId + clientSecret + enterpriseId (client credentials), or clientId + refreshToken (OAuth)'); } if (config.clientId === undefined || config.clientId === '') { throw new Error('BoxTokenManager: clientId is required when using refreshToken'); } } this.currentRefreshToken = config.refreshToken ?? ''; if (this.devTokenMode && config.accessToken !== undefined) { // Mark as never-expires from our side; Box itself will 401 after ~1h and // the user has to update the env var manually. this.seed(config.accessToken, Number.POSITIVE_INFINITY); } } async refreshPair() { if (this.devTokenMode) { // Unreachable while the seeded expiry is +Infinity, but keep the // branch honest: a dev token can't be refreshed. throw new BoxApiError('Box developer token expired (~1 hour lifetime). Regenerate it in the app console and update BOX_ACCESS_TOKEN.', 401); } if (this.ccgMode) { const ccg = await fetchCcgToken(this.config); return [ccg.accessToken, ccg.expiresIn]; } let result; if (this.config.refreshFn !== undefined) { result = await this.config.refreshFn(this.currentRefreshToken); } else { result = await refreshAccessToken(this.config, this.currentRefreshToken); } if (result.refreshToken !== this.currentRefreshToken) { this.currentRefreshToken = result.refreshToken; if (this.config.onRefreshTokenRotated !== undefined) { await this.config.onRefreshTokenRotated(result.refreshToken); } } return [result.accessToken, result.expiresIn]; } } async function boxAuthHeaders(tm) { const token = await tm.getToken(callerSignal()); return { Authorization: `Bearer ${token}` }; } function buildUrl(url, params) { if (params === undefined) return url; const u = new URL(url); for (const [k, v] of Object.entries(params)) u.searchParams.set(k, String(v)); return u.toString(); } export async function boxGet(tm, url, params) { return apiRequest('GET', url, { timeout: tm.timeout, errorOf: errorOf('GET', url), headers: await boxAuthHeaders(tm), params, }); } export async function boxOptions(tm, url) { return apiRequest('OPTIONS', url, { timeout: tm.timeout, errorOf: errorOf('OPTIONS', url), headers: await boxAuthHeaders(tm), }); } export async function boxGetBytes(tm, url, params, window) { const data = await apiRequest('GET', url, { timeout: tm.timeout, errorOf: errorOf('GET', url), headers: await boxAuthHeaders(tm), params, read: 'bytes', window, }); return data; } export async function* boxGetStream(tm, url, params) { const headers = await boxAuthHeaders(tm); const r = await stallTimeout(tm.timeout)(buildUrl(url, params), { headers, redirect: 'follow' }); if (!r.ok) { const text = await r.text().catch(() => ''); throw new BoxApiError(`Box GET ${url} → ${String(r.status)} ${text}`, r.status); } if (r.body === null) return; const reader = r.body.getReader(); for (;;) { const { value, done } = await reader.read(); if (done) break; yield value; } } export async function boxPostJson(tm, url, body) { return apiRequest('POST', url, { timeout: tm.timeout, errorOf: errorOf('POST', url), headers: { ...(await boxAuthHeaders(tm)), 'Content-Type': 'application/json' }, json: body, }); } export async function boxPutJson(tm, url, body) { return apiRequest('PUT', url, { timeout: tm.timeout, errorOf: errorOf('PUT', url), headers: { ...(await boxAuthHeaders(tm)), 'Content-Type': 'application/json' }, json: body, }); } export async function boxDelete(tm, url, params, headers = {}, signal) { await apiRequest('DELETE', url, { signal, timeout: tm.timeout, errorOf: errorOf('DELETE', url), headers: { ...(await boxAuthHeaders(tm)), ...headers }, params, read: 'none', }); } export async function boxUploadMultipart(tm, url, attributes, filename, data, headers = {}) { const form = new FormData(); form.set('attributes', JSON.stringify(attributes)); form.set('file', new Blob([data]), filename); return apiRequest('POST', url, { timeout: tm.timeout, errorOf: errorOf('upload', url), headers: { ...(await boxAuthHeaders(tm)), ...headers }, body: form, }); } function digestHeader(digest) { const hex = digest.digest(); return ('sha=' + encodeBase64(Uint8Array.from({ length: 20 }, (_, i) => Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16)))); } export async function boxUploadSession(tm, url, metadata, spool, etag = null) { const created = (await boxPostJson(tm, url, { ...metadata, file_size: spool.size })); const endpoints = created.session_endpoints; const partSize = created.part_size; const parts = []; const digest = new Sha1(); try { if (!Number.isSafeInteger(partSize) || partSize <= 0 || partSize > 64 * 1024 * 1024) throw new Error('Invalid Box upload part size'); for (let offset = 0; offset < spool.size; offset += partSize) { const data = await spool.read(offset, partSize); const partDigest = new Sha1(); for await (const chunk of chunks(data)) { digest.update(chunk); partDigest.update(chunk); } const response = (await apiRequest('PUT', endpoints.upload_part, { timeout: tm.timeout, errorOf: errorOf('upload part', url), headers: { ...(await boxAuthHeaders(tm)), 'Content-Type': 'application/octet-stream', Digest: digestHeader(partDigest), 'Content-Range': `bytes ${String(offset)}-${String(offset + data.length - 1)}/${String(spool.size)}`, }, body: data, })); parts.push(response.part); } const headers = { Digest: digestHeader(digest), 'Content-Type': 'application/json', }; if (etag !== null) headers['If-Match'] = etag; for (let attempt = 0; attempt < 10; attempt++) { const response = (await apiRequest('POST', endpoints.commit, { timeout: tm.timeout, errorOf: errorOf('commit upload', url), headers: { ...(await boxAuthHeaders(tm)), ...headers }, json: { parts }, read: 'response', })); if (response.status === 201) return response.data; if (response.status !== 202) throw new Error(`Unexpected Box commit status: ${String(response.status)}`); const delay = Number(response.headers['retry-after'] ?? '1'); await tm.clock.sleep(Math.max(0, Math.min(Number.isFinite(delay) ? delay : 1, 30))); } throw new Error('Box upload commit did not complete'); } catch (error) { try { // A stopped upload still ends its session, under a deadline of its // own rather than the stopped call's signal; a timer takes whole // milliseconds. const signal = AbortSignal.timeout(Math.ceil((tm.timeout ?? DEFAULT_TIMEOUT) * 1000)); await boxDelete(tm, endpoints.abort, undefined, {}, signal); } catch (cleanup) { console.debug('Could not abort Box upload session', cleanup); } throw error; } } //# sourceMappingURL=client.js.map