@opendatalabs/vana-sdk
Version:
A TypeScript library for interacting with Vana Network smart contracts.
231 lines • 6.98 kB
JavaScript
import {
StorageError
} from "../index.js";
import {
buildWeb3SignedHeader
} from "../../auth/web3-signed-builder.js";
const DEFAULT_ENDPOINT = "https://storage.vana.org";
const BLOB_PATH_PREFIX = "/v1/blobs";
const DEFAULT_TOKEN_TTL_SECONDS = 300;
class VanaStorage {
endpoint;
signer;
ownerAddress;
fetchImpl;
constructor(config) {
if (!config?.signer?.address || !config?.signer?.signMessage) {
throw new StorageError(
"VanaStorage requires a signer with address and signMessage",
"MISSING_SIGNER",
"vana-storage"
);
}
this.endpoint = (config.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "");
this.signer = config.signer;
this.ownerAddress = (config.ownerAddress ?? config.signer.address).toLowerCase();
this.fetchImpl = config.fetchImpl ?? globalThis.fetch.bind(globalThis);
}
/**
* Upload an encrypted blob to vana-storage.
*
* @param file - The blob to upload.
* @param filename - Required relative key in the form `"{scope}/{collectedAt}"`.
* The owner address is prepended automatically.
*/
async upload(file, filename) {
if (!filename) {
throw new StorageError(
"VanaStorage.upload requires a filename of the form '{scope}/{collectedAt}'",
"MISSING_FILENAME",
"vana-storage"
);
}
const subpath = encodeRelativePath(filename);
const path = `${BLOB_PATH_PREFIX}/${this.ownerAddress}/${subpath}`;
const body = new Uint8Array(await file.arrayBuffer());
const contentType = file.type !== "" ? file.type : "application/octet-stream";
const header = await this.signRequest("PUT", path, body);
let response;
try {
response = await this.fetchImpl(`${this.endpoint}${path}`, {
method: "PUT",
headers: {
authorization: header,
"content-type": contentType
},
body
});
} catch (cause) {
throw new StorageError(
`vana-storage upload network error: ${describe(cause)}`,
"UPLOAD_ERROR",
"vana-storage",
{ cause: cause instanceof Error ? cause : void 0 }
);
}
if (!response.ok) {
throw new StorageError(
`vana-storage upload failed: ${response.status} ${response.statusText} - ${await safeText(response)}`,
"UPLOAD_FAILED",
"vana-storage"
);
}
const result = await response.json();
return {
url: result.url,
size: result.size,
contentType,
metadata: { key: result.key, etag: result.etag }
};
}
/**
* Download a blob by URL. The URL must point at a path under this
* provider's endpoint.
*/
async download(url) {
const path = this.pathFromUrl(url);
const header = await this.signRequest("GET", path);
let response;
try {
response = await this.fetchImpl(`${this.endpoint}${path}`, {
method: "GET",
headers: { authorization: header }
});
} catch (cause) {
throw new StorageError(
`vana-storage download network error: ${describe(cause)}`,
"DOWNLOAD_ERROR",
"vana-storage",
{ cause: cause instanceof Error ? cause : void 0 }
);
}
if (!response.ok) {
throw new StorageError(
`vana-storage download failed: ${response.status} ${response.statusText}`,
"DOWNLOAD_FAILED",
"vana-storage"
);
}
return await response.blob();
}
/**
* Listing is not supported by vana-storage — file discovery is handled by
* the Gateway DataRegistry, not the storage layer.
*/
async list(_options) {
throw new StorageError(
"list is not supported by vana-storage; query the Gateway DataRegistry instead",
"NOT_IMPLEMENTED",
"vana-storage"
);
}
async delete(url) {
const path = this.pathFromUrl(url);
const header = await this.signRequest("DELETE", path);
let response;
try {
response = await this.fetchImpl(`${this.endpoint}${path}`, {
method: "DELETE",
headers: { authorization: header }
});
} catch (cause) {
throw new StorageError(
`vana-storage delete network error: ${describe(cause)}`,
"DELETE_ERROR",
"vana-storage",
{ cause: cause instanceof Error ? cause : void 0 }
);
}
if (response.status === 404) return false;
if (!response.ok) {
throw new StorageError(
`vana-storage delete failed: ${response.status} ${response.statusText}`,
"DELETE_FAILED",
"vana-storage"
);
}
return true;
}
getConfig() {
return {
name: "vana-storage",
type: "vana-storage",
requiresAuth: true,
features: {
upload: true,
download: true,
list: false,
delete: true
}
};
}
async signRequest(method, path, body) {
const now = Math.floor(Date.now() / 1e3);
return buildWeb3SignedHeader({
signMessage: this.signer.signMessage,
aud: this.endpoint,
method,
uri: path,
iat: now,
exp: now + DEFAULT_TOKEN_TTL_SECONDS,
...body !== void 0 && body.length > 0 && { body }
});
}
pathFromUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
throw new StorageError(
`Invalid URL: ${url}`,
"INVALID_URL",
"vana-storage"
);
}
const expectedHost = new URL(this.endpoint).host;
if (parsed.host !== expectedHost) {
throw new StorageError(
`URL host '${parsed.host}' does not match storage endpoint '${expectedHost}'`,
"INVALID_URL",
"vana-storage"
);
}
const segments = parsed.pathname.split("/").filter((s) => s.length > 0);
const isTraversal = (s) => s === "." || s === "..";
const valid = segments.length === 5 && segments[0] === "v1" && segments[1] === "blobs" && segments[2]?.toLowerCase() === this.ownerAddress && segments[3] !== void 0 && !isTraversal(segments[3]) && segments[4] !== void 0 && !isTraversal(segments[4]);
if (!valid) {
throw new StorageError(
`URL path '${parsed.pathname}' must be /v1/blobs/${this.ownerAddress}/{scope}/{collectedAt}`,
"INVALID_URL",
"vana-storage"
);
}
return parsed.pathname;
}
}
function encodeRelativePath(filename) {
const parts = filename.split("/");
if (parts.length !== 2 || parts.some((p) => p.length === 0 || p === "." || p === "..")) {
throw new StorageError(
`filename must be exactly '{scope}/{collectedAt}' with non-empty segments, got '${filename}'`,
"INVALID_FILENAME",
"vana-storage"
);
}
return parts.map((p) => encodeURIComponent(p)).join("/");
}
function describe(value) {
if (value instanceof Error) return value.message;
return String(value);
}
async function safeText(response) {
try {
return await response.text();
} catch {
return "";
}
}
export {
VanaStorage
};
//# sourceMappingURL=vana-storage.js.map