@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
176 lines • 7.07 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 { eacces } from "../../errors/fs.js";
import { windowFor } from "../../utils/ranges.js";
import { apiRequest, apiStream } from "../api/client.js";
import { ApiError } from "../api/errors.js";
export const REFUSED_ERRORS = new Set([
'not_in_channel',
'channel_not_found',
'missing_scope',
'is_archived',
'not_authed',
]);
export class SlackApiError extends ApiError {
endpoint;
slackError;
needed;
provided;
constructor(endpoint, slackError, needed = null, provided = null, status = null) {
super(formatSlackErrorMessage(endpoint, slackError, needed, provided), status);
this.endpoint = endpoint;
this.slackError = slackError;
this.needed = needed;
this.provided = provided;
this.name = 'SlackApiError';
}
}
/**
* Report a conversation Slack will not show as permission denied.
*
* `not_in_channel`, `missing_scope` and the rest are Slack declining to show
* what the path names, never the conversation saying it is empty, so the path
* answers the way a directory the caller may not open does: ls names it and
* fails, find reports it and walks on.
*/
export async function refusalsDenied(pathSpec, run) {
try {
return await run();
}
catch (err) {
if (err instanceof SlackApiError && REFUSED_ERRORS.has(err.slackError))
throw eacces(pathSpec);
throw err;
}
}
function formatSlackErrorMessage(endpoint, slackError, needed, provided) {
const base = `Slack API error (${endpoint}): ${slackError}`;
if (slackError !== 'missing_scope' || needed === null || needed === '')
return base;
const providedRepr = provided !== null && provided !== '' ? provided : '(none)';
return `${base} (needed: ${needed}; provided: ${providedRepr})`;
}
// Slack reports failures as ok:false payloads, usually with a 200; a
// non-2xx that still carries one keeps Slack's own wording.
function errorOf(endpoint, response, text) {
let data = null;
try {
data = JSON.parse(text);
}
catch {
data = null;
}
if (data !== null && typeof data === 'object') {
const payload = data;
return new SlackApiError(endpoint, payload.error ?? 'unknown_error', payload.needed ?? null, payload.provided ?? null, response.status);
}
return new SlackApiError(endpoint, `HTTP ${String(response.status)}`, null, null, response.status);
}
export class HttpSlackTransport {
timeout;
// Indirection so tests can inject a fake fetch without subclass plumbing.
fetch = globalThis.fetch.bind(globalThis);
constructor(timeout) {
this.timeout = timeout;
}
async call(endpoint, params, body) {
const base = this.baseUrl().replace(/\/$/, '');
const auth = await this.authHeaders(endpoint);
const raw = await apiRequest(body === undefined ? 'GET' : 'POST', `${base}/${endpoint}`, {
errorOf: (response, text) => errorOf(endpoint, response, text),
headers: { 'Content-Type': 'application/json; charset=utf-8', ...auth },
...(params === undefined ? {} : { params }),
...(body === undefined ? {} : { json: body }),
fetchFn: this.fetch,
timeout: this.timeout,
});
const data = (raw ?? {});
if (!data.ok) {
throw new SlackApiError(endpoint, data.error ?? 'unknown_error', data.needed ?? null, data.provided ?? null);
}
return data;
}
// Takes the window rather than a prepared header so the answer can be
// checked against it: Slack serves files from a CDN, and a server is free
// to ignore Range and reply 200 with the whole file, which the kit's
// window handling then trims.
async downloadFile(url, offset = 0, size = null) {
const auth = await this.authHeaders();
const window = windowFor(offset, size);
const data = await apiRequest('GET', url, {
errorOf: (response) => new ApiError(`slack: download failed (${String(response.status)}): ${url}`, response.status),
headers: auth,
read: 'bytes',
...(window === undefined ? {} : { window }),
fetchFn: this.fetch,
timeout: this.timeout,
});
return data;
}
// The URL files.getUploadURLExternal issues is presigned, so the request
// carries no token.
async uploadBytes(url, data) {
await apiRequest('POST', url, {
errorOf: (response) => new ApiError(`slack: upload failed (${String(response.status)}): ${url}`, response.status),
body: data,
read: 'none',
fetchFn: this.fetch,
timeout: this.timeout,
});
}
async *downloadFileStream(url, signal) {
yield* apiStream(url, {
errorOf: (response) => new ApiError(`slack: download failed (${String(response.status)}): ${url}`, response.status),
headers: await this.authHeaders(),
fetchFn: this.fetch,
timeout: this.timeout,
...(signal === undefined ? {} : { signal }),
});
}
}
export class NodeSlackTransport extends HttpSlackTransport {
token;
searchToken;
baseUrlOverride;
constructor(token, searchToken, baseUrlOverride, timeout) {
super(timeout);
this.token = token;
this.searchToken = searchToken;
this.baseUrlOverride = baseUrlOverride;
}
baseUrl() {
return this.baseUrlOverride !== undefined && this.baseUrlOverride !== ''
? this.baseUrlOverride
: 'https://slack.com/api';
}
authHeaders(endpoint) {
const token = endpoint?.startsWith('search.') === true &&
this.searchToken !== undefined &&
this.searchToken !== ''
? this.searchToken
: this.token;
return { Authorization: `Bearer ${token}` };
}
searchAvailable() {
if (this.searchToken !== undefined && this.searchToken !== '')
return true;
return this.token.startsWith('xoxp-');
}
searcher() {
if (this.searchToken === undefined || this.searchToken === '')
return null;
return new NodeSlackTransport(this.searchToken, undefined, this.baseUrlOverride, this.timeout);
}
}
//# sourceMappingURL=client.js.map