@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
348 lines • 14.3 kB
JavaScript
// ========= 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