diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..0bd1341 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(*)" + ], + "deny": [] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..5e43a4c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,29 @@ +{ + "permissions": { + "allow": [ + "Read(///wsl.localhost/Ubuntu/home/anurag/IPFS-meshkit/packages/core/**)", + "Read(///wsl.localhost/Ubuntu/home/anurag/IPFS-meshkit/packages/meshkit/**)", + "Read(///wsl.localhost/Ubuntu/home/anurag/IPFS-meshkit/packages/node/**)", + "Read(///wsl.localhost/Ubuntu/home/anurag/IPFS-meshkit/packages/mcp/**)", + "Read(///wsl.localhost/Ubuntu/home/anurag/IPFS-meshkit/packages/mcp/src/**)", + "Read(///wsl.localhost/Ubuntu/home/anurag/IPFS-meshkit/packages/capacitor/**)", + "Read(///wsl.localhost/Ubuntu/home/anurag/IPFS-meshkit/packages/react-native/**)", + "Skill(fewer-permission-prompts)", + "Bash(awk '{print $2}')", + "Bash(xargs '-I{}' sh -c ' *)", + "WebFetch(domain:docs.filecoin.io)", + "WebFetch(domain:www.npmjs.com)", + "WebFetch(domain:filecoin.one)", + "WebFetch(domain:web.archive.org)", + "WebFetch(domain:github.com)", + "WebFetch(domain:docs.synapse.storage)", + "WebFetch(domain:docs.filecoin.cloud)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:synapse.filecoin.services)", + "WebFetch(domain:www.google.com)", + "WebFetch(domain:lanzafame.github.io)", + "WebFetch(domain:app.fil.one)", + "WebFetch(domain:docs.fil.one)" + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index af10b83..d996af0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog +## 1.2.0 — 2026-07-22 + +### Added + +- **`createS3Client(config)`** — universal `MeshkitClient` backed by any S3-compatible object store (fil.one, Lighthouse, Filebase, 4EVERLAND, etc.) + - `config.endpoint` is now required and explicit — point it at any S3-compatible service + - All upload/retrieve/pin/healthCheck logic is unchanged from the previous `createFilOneClient` implementation + - **`listPins()`** — now works on S3 clients: issues a real `ListObjectsV2` XML request, handles pagination via `ContinuationToken`, filters out non-CID keys (any key containing `.` is excluded) + - **`list()`** — new method; returns `StoredObject[]` with `{ cid, size, uploadedAt }` for every object in the bucket (same pagination and filtering as `listPins()`) +- **`StoredObject`** interface — `{ cid: string; size: number; uploadedAt: string }` (ISO 8601); exported from the package root +- **`S3StorageConfig`** interface — `{ accessKeyId, secretAccessKey, bucket, endpoint }`; exported from the package root +- **`list(): Promise`** added to the `MeshkitClient` and `Meshkit` interfaces + - Throws `MeshkitError` on Kubo clients (`createMeshkitClient`, `Meshkit` class) — use `listPins()` on the Kubo path instead +- **Browser / Capacitor build** — a separate `dist/index.browser.{js,cjs,d.ts}` entry is now built and wired to the `browser` export condition in `package.json`; Vite, Capacitor, and React Native bundlers resolve this automatically and no longer encounter `child_process` / `path` errors + +### Changed + +- **`createFilOneClient`** is now a thin preset over `createS3Client` — it passes through all config and defaults `endpoint` to `https://eu-west-1.s3.fil.one`. The exported `FilOneConfig` type is unchanged and `endpoint` remains optional. No breaking change. +- **`listPins()` on S3 clients** previously threw `MeshkitError`; it now returns real data. If you were catching that error to detect S3 backends, use `list()` as the feature-detection check instead. + +### Notes + +- `createS3Client` and `createFilOneClient` both implement the same `MeshkitClient` interface — they are drop-in replacements for each other and for `createMeshkitClient` wherever a `MeshkitClient` is accepted +- Store the CID returned by `upload()` in your own database — there is no IPFS DHT lookup on the S3 path; the CID is the S3 object key and is only retrievable if you hold it + +## 1.1.0 — 2026-07-22 + +### Added + +- **`createFilOneClient(config)`** — new `MeshkitClient` backed by [fil.one](https://fil.one) (Filecoin-native S3-compatible object storage) instead of a Kubo daemon + - CID is computed locally (CIDv1, raw codec, sha2-256) — no IPFS node required + - Objects stored at `//` via AWS Signature V4 (`aws4fetch`) + - `upload()` issues a signed PUT; `retrieve()` issues a signed GET + - `pin()` is a silent no-op (object store has no pin layer, so callers don't need special-casing) + - `healthCheck()` issues a signed HEAD on the bucket — validates credentials and bucket existence + - IPNS operations (`publishName`, `resolveName`, `resolveAndRetrieve`, `generateKey`, `listKeys`, `listPins`) throw `MeshkitError` with a clear message explaining the limitation +- **`FilOneConfig`** interface exported from the root package — `accessKeyId`, `secretAccessKey`, `bucket`, optional `endpoint` (default: `https://eu-west-1.s3.fil.one`) +- `multiformats` added as a dependency for local CID computation + +### Notes + +- `createFilOneClient` is a drop-in `MeshkitClient` — works anywhere `createMeshkitClient` works, including Ionic/Capacitor, React Native, and browser environments (no daemon spawn required) +- Do not use `init()` or `startIPFSNode()` with fil.one — those are Node.js daemon paths; use `createFilOneClient` directly + +## 1.0.2 — 2026-07-11 + +### Added + +- `Meshkit.listPins()` and `MeshkitClient.listPins()` — list pinned CIDs on the primary node via `kubo-rpc-client` (respects RPC auth headers) + +### Notes + +- Publish `@ipfs-meshkit/meshkit@1.0.2` before `@ipfs-meshkit/mcp@1.0.0`; MCP depends on `meshkit.listPins()`. + ## 1.0.1 — 2026-07-06 ### Fixed diff --git a/README.md b/README.md index 8118672..786cab3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # IPFS Meshkit0 -**@ipfs-meshkit/meshkit** is a Node.js SDK for [Kubo](https://docs.ipfs.tech/) (IPFS). Upload, retrieve, and pin content over Kubo’s HTTP RPC, with optional local daemon startup, IPNS, and multi-node failover. +**@ipfs-meshkit/meshkit** is a TypeScript SDK for decentralized storage with two backends and one unified interface: + +- **Kubo / IPFS** — for Node.js and server apps that can run a daemon +- **S3-compatible object storage** (fil.one, Lighthouse, Filebase, 4EVERLAND, …) — for browsers, Ionic/Capacitor, and mobile apps where no daemon is possible + +Both backends implement the same `MeshkitClient` interface (`upload`, `retrieve`, `pin`, `list`, `listPins`, …) so you can swap backends without changing application logic. [![test](https://github.com/IPFS-Meshkit/meshkit0/actions/workflows/test.yml/badge.svg)](https://github.com/IPFS-Meshkit/meshkit0/actions/workflows/test.yml) [![npm version](https://img.shields.io/npm/v/@ipfs-meshkit/meshkit.svg)](https://www.npmjs.com/package/@ipfs-meshkit/meshkit) @@ -9,15 +14,42 @@ [![NPM](https://nodei.co/npm/@ipfs-meshkit/meshkit.png?downloads=true&downloadRank=true)](https://www.npmjs.com/package/@ipfs-meshkit/meshkit) +--- + ## Install ```bash npm install @ipfs-meshkit/meshkit ``` -**Requirements:** Node.js **20+**. For `localNode: true`, install [Kubo](https://docs.ipfs.tech/install/) and ensure `ipfs` is on your `PATH`. +**Requirements:** Node.js **20+**. +- **Kubo path** (`localNode: true`): install [Kubo](https://docs.ipfs.tech/install/) and ensure `ipfs` is on your `PATH`. +- **S3 path** (`createS3Client` / `createFilOneClient`): no daemon needed — works in browsers, Ionic/Capacitor, and React Native. + +--- + +## How it works + +``` +Two backends, one MeshkitClient interface +───────────────────────────────────────────────────────────────────── + + Kubo / IPFS path (Node.js, server) + ──────────────────────────────────────────────────────────────── + init() → Kubo RPC (:5001) → ./.ipfs on disk + upload · retrieve · pin · IPNS · listPins · failover + + S3-compatible path (browser, Ionic, mobile — no daemon) + ──────────────────────────────────────────────────────────────── + createS3Client() → fil.one / Lighthouse / Filebase / … + upload · retrieve · list · listPins +``` -## Quick start +The S3 path computes a CID locally (CIDv1, raw codec, sha2-256) from the raw bytes and uses it as the S3 object key. **This CID is not published to the IPFS network** — it is a content-addressed key private to your bucket. Store the CID in your own database so you can retrieve the file later. + +--- + +## Quick start — Kubo (Node.js) ```typescript import { init, setupGracefulShutdown } from '@ipfs-meshkit/meshkit'; @@ -25,34 +57,54 @@ import { init, setupGracefulShutdown } from '@ipfs-meshkit/meshkit'; const { meshkit, localNode } = await init({ localNode: true }); setupGracefulShutdown(localNode); -const bytes = new TextEncoder().encode('hello'); +const bytes = new TextEncoder().encode('hello world'); const cid = await meshkit.upload(bytes); await meshkit.pin(cid); const retrieved = await meshkit.retrieve(cid); -console.log(new TextDecoder().decode(retrieved)); // hello +console.log(new TextDecoder().decode(retrieved)); // hello world ``` -`localNode: true` starts or attaches to Kubo on `http://127.0.0.1:5001` and stores data in `./.ipfs`. Add `.ipfs` to `.gitignore`. +`localNode: true` starts or attaches to a Kubo daemon on `http://127.0.0.1:5001` and stores data in `./.ipfs`. Add `.ipfs` to `.gitignore`. -## How it works +--- -``` -Your app → init() → Kubo RPC (:5001) → ./.ipfs on disk - upload · retrieve · pin · IPNS +## Quick start — S3-compatible storage (browser / Ionic / mobile) + +```typescript +import { createS3Client } from '@ipfs-meshkit/meshkit'; + +const client = createS3Client({ + accessKeyId: process.env.STORAGE_KEY!, + secretAccessKey: process.env.STORAGE_SECRET!, + bucket: 'my-bucket', + endpoint: 'https://', // fil.one, Lighthouse, Filebase, 4EVERLAND, … +}); + +// Upload — CID is computed locally from the raw bytes +const bytes = new TextEncoder().encode('invoice payload'); +const cid = await client.upload(bytes); +console.log('stored at key:', cid); // bafkrei... + +// Retrieve — fetch back by the same CID +const retrieved = await client.retrieve(cid); + +// List everything in the bucket +const objects = await client.list(); +// → [{ cid: 'bafkrei...', size: 14, uploadedAt: '2026-07-22T10:00:00.000Z' }, ...] ``` -1. **`init()`** — connect to one or more Kubo nodes; optionally spawn a local daemon. -2. **`meshkit`** — storage API with failover (`upload`, `retrieve`, `pin`, IPNS). -3. **`setupGracefulShutdown()`** — stop a managed Kubo cleanly on Ctrl+C / SIGTERM. +> **Store your CIDs.** There is no directory listing backed by IPFS. `list()` queries the S3 bucket directly. If you lose the CID and the bucket is empty, there is no way to rediscover the file from the IPFS network. + +--- ## Usage -### Local Kubo (recommended for Node.js) +### Local Kubo daemon (recommended for Node.js servers) ```typescript import { readFile } from 'node:fs/promises'; -import { init, listPins, setupGracefulShutdown } from '@ipfs-meshkit/meshkit'; +import { init, setupGracefulShutdown } from '@ipfs-meshkit/meshkit'; const { meshkit, localNode } = await init({ localNode: true }); setupGracefulShutdown(localNode); @@ -61,13 +113,11 @@ const file = await readFile('./document.pdf'); const cid = await meshkit.upload(file); await meshkit.pin(cid); -const pins = await listPins(meshkit.activeNodes[0]!); -console.log('pinned:', pins.length); +const pins = await meshkit.listPins(); +console.log('pinned CIDs:', pins.length); ``` -### Remote Kubo only (no local spawn) - -Use when Kubo already runs on a server or LAN machine: +### Remote Kubo only (no local daemon spawn) ```typescript import { init } from '@ipfs-meshkit/meshkit'; @@ -79,38 +129,46 @@ const { meshkit } = await init({ const cid = await meshkit.upload(new Uint8Array([1, 2, 3])); ``` -### Local + backup nodes (failover) +### Multi-node failover + +Meshkit health-checks all nodes at init time and routes requests through the first healthy one. If it fails mid-operation, the next node is tried automatically. ```typescript import { init } from '@ipfs-meshkit/meshkit'; const { meshkit } = await init({ localNode: true, - nodes: ['https://backup.example.com:5001'], + nodes: ['https://backup1.example.com:5001', 'https://backup2.example.com:5001'], }); -const backup = new TextEncoder().encode('failover example'); -await meshkit.upload(backup); +await meshkit.upload(new TextEncoder().encode('stored across failover nodes')); ``` -### IPNS (mutable pointer) +### IPNS — mutable pointers + +IPNS lets you publish a stable name that always points to your latest content. Only available on the Kubo path. ```typescript import { init, IPNS_TTL_FAST } from '@ipfs-meshkit/meshkit'; const { meshkit } = await init({ localNode: true }); -await meshkit.generateKey('latest'); -const contentBytes = new TextEncoder().encode('v1'); -const cid = await meshkit.upload(contentBytes); -await meshkit.pin(cid); // publishName does not pin — pin content you care about +// Create a named key — the key name becomes a stable IPNS address +await meshkit.generateKey('my-app'); +const v1 = new TextEncoder().encode('version 1'); +const cid = await meshkit.upload(v1); +await meshkit.pin(cid); + +// Publish: /ipns/ now resolves to this CID const { name } = await meshkit.publishName(cid, { - key: 'latest', + key: 'my-app', ttl: IPNS_TTL_FAST, }); +// Later — resolve and fetch in one call const latest = await meshkit.resolveAndRetrieve(`/ipns/${name}`); +console.log(new TextDecoder().decode(latest)); // version 1 ``` ### Server shutdown hook @@ -122,12 +180,106 @@ const { meshkit, localNode } = await init({ localNode: true }); setupGracefulShutdown(localNode, { onShutdown: async () => { - // close HTTP server, DB, etc. + // close HTTP servers, DB connections, etc. }, }); ``` -### Low-level client (single node, no failover) +### S3-compatible storage — fil.one preset + +`createFilOneClient` is a convenience preset over `createS3Client` that defaults `endpoint` to `https://eu-west-1.s3.fil.one`. Get your API key and bucket at [app.fil.one](https://app.fil.one). + +```typescript +import { createFilOneClient } from '@ipfs-meshkit/meshkit'; + +const client = createFilOneClient({ + accessKeyId: process.env.FIL_ACCESS_KEY!, // format: FHXXXXXXXXXXXXXXXX + secretAccessKey: process.env.FIL_SECRET_KEY!, + bucket: 'my-invoices', + // endpoint defaults to 'https://eu-west-1.s3.fil.one' +}); + +const cid = await client.upload(new TextEncoder().encode('invoice data')); +const retrieved = await client.retrieve(cid); +``` + +### S3-compatible storage — custom endpoint (Lighthouse, Filebase, 4EVERLAND, …) + +`createS3Client` works with any S3-compatible service. Pass the endpoint explicitly. + +```typescript +import { createS3Client } from '@ipfs-meshkit/meshkit'; + +// Lighthouse +const lighthouse = createS3Client({ + accessKeyId: process.env.LH_KEY!, + secretAccessKey: process.env.LH_SECRET!, + bucket: 'my-bucket', + endpoint: 'https://gateway.lighthouse.storage', +}); + +// Filebase +const filebase = createS3Client({ + accessKeyId: process.env.FB_KEY!, + secretAccessKey: process.env.FB_SECRET!, + bucket: 'my-bucket', + endpoint: 'https://s3.filebase.com', +}); + +const cid = await lighthouse.upload(new TextEncoder().encode('data')); +``` + +### Listing stored objects + +On S3 clients, `list()` queries the bucket and returns metadata for every stored object. `listPins()` returns just the CID strings (same data, different shape). + +```typescript +import { createS3Client } from '@ipfs-meshkit/meshkit'; + +const client = createS3Client({ /* config */ }); + +// Full metadata +const objects = await client.list(); +for (const obj of objects) { + console.log(obj.cid); // 'bafkrei...' + console.log(obj.size); // 1024 (bytes) + console.log(obj.uploadedAt); // '2026-07-22T10:00:00.000Z' +} + +// CID strings only +const cids = await client.listPins(); +``` + +> `list()` is **not available on Kubo clients** — it throws `MeshkitError`. Use `listPins()` on the Kubo path. + +### In an Ionic / Angular service + +```typescript +import { Injectable } from '@angular/core'; +import { createFilOneClient, type MeshkitClient } from '@ipfs-meshkit/meshkit'; +import { environment } from '../../environments/environment'; + +@Injectable({ providedIn: 'root' }) +export class InvoiceStorageService { + private client: MeshkitClient = createFilOneClient(environment.filOne); + + async uploadInvoice(file: File): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + return this.client.upload(bytes); // returns CID — store this in your DB + } + + async getInvoice(cid: string): Promise { + const bytes = await this.client.retrieve(cid); + return new Blob([bytes], { type: 'application/pdf' }); + } + + async listInvoices() { + return this.client.list(); // [{ cid, size, uploadedAt }, ...] + } +} +``` + +### Low-level single-node client (no failover) ```typescript import { createMeshkitClient } from '@ipfs-meshkit/meshkit'; @@ -147,25 +299,71 @@ const { init } = require('@ipfs-meshkit/meshkit'); })(); ``` +--- + ## API overview +### Kubo / IPFS + | Export | Purpose | -|--------|---------| -| `init()` | Main entry — connect (+ optional `localNode`) | -| `meshkit.upload` / `retrieve` / `pin` | Storage with failover | -| `meshkit.publishName` / `resolveName` / `resolveAndRetrieve` | IPNS | -| `meshkit.generateKey` / `listKeys` | IPNS keystore | -| `setupGracefulShutdown` | Graceful Kubo shutdown | -| `listPins` | List pinned CIDs (migration / backups) | -| `createMeshkitClient` | Single-node RPC client | -| `startIPFSNode` / `stopIPFSNode` | Low-level daemon control | - -TypeScript types are included (`import type { Meshkit, IPFSNodeHandle } from '@ipfs-meshkit/meshkit'`). +|---|---| +| `init(options?)` | Main entry — connect to Kubo nodes, optionally spawn local daemon | +| `meshkit.upload(bytes)` | Upload bytes; returns CID string; uses failover | +| `meshkit.retrieve(cid)` | Retrieve bytes by CID; uses failover | +| `meshkit.pin(cid)` | Pin a CID on the primary node | +| `meshkit.listPins()` | List all pinned CIDs on the primary node | +| `meshkit.publishName` / `resolveName` / `resolveAndRetrieve` | IPNS mutable pointers | +| `meshkit.generateKey` / `listKeys` | IPNS keystore management | +| `setupGracefulShutdown` | Stop managed Kubo on Ctrl+C / SIGTERM | +| `createMeshkitClient(config)` | Single-node Kubo RPC client (no failover) | +| `startIPFSNode` / `stopIPFSNode` | Low-level daemon lifecycle control | + +### S3-compatible object storage + +| Export | Purpose | +|---|---| +| `createS3Client(config)` | `MeshkitClient` backed by any S3-compatible service | +| `createFilOneClient(config)` | Preset of `createS3Client` for fil.one; `endpoint` defaults to `eu-west-1.s3.fil.one` | +| `client.upload(bytes)` | PUT bytes to the bucket; CID computed locally and used as the object key | +| `client.retrieve(cid)` | GET bytes from the bucket by CID key | +| `client.list()` | List all objects: `{ cid, size, uploadedAt }[]`; handles pagination | +| `client.listPins()` | Same as `list()` but returns only the CID strings | +| `client.pin(cid)` | No-op — safe to call, does nothing (no pin layer on object stores) | +| `client.healthCheck()` | HEAD the bucket — confirms credentials and bucket exist | +| `S3StorageConfig` | Type: `{ accessKeyId, secretAccessKey, bucket, endpoint }` | +| `FilOneConfig` | Type: `{ accessKeyId, secretAccessKey, bucket, endpoint? }` | +| `StoredObject` | Type: `{ cid: string; size: number; uploadedAt: string }` | + +> IPNS operations (`publishName`, `resolveName`, `resolveAndRetrieve`, `generateKey`, `listKeys`) are **not supported on S3 clients** and throw `MeshkitError`. + +### Constants and utilities + +| Export | Purpose | +|---|---| +| `IPNS_TTL_FAST` / `IPNS_TTL_DEFAULT` | TTL constants for IPNS publish | +| `extractCidFromPath(path)` | Extract CID string from `/ipfs/` path | +| `toIpfsPath(cid)` / `toIpnsPath(name)` | Normalize to `/ipfs/` or `/ipns/` path | +| `MeshkitError` / `MeshkitNodeError` | Error classes | + +TypeScript types are included — no `@types/` package needed: + +```typescript +import type { + MeshkitClient, + StoredObject, + S3StorageConfig, + FilOneConfig, + IPFSNodeHandle, +} from '@ipfs-meshkit/meshkit'; +``` + +--- ## Documentation - [GitHub repository](https://github.com/IPFS-Meshkit/meshkit0) - [Kubo install guide](https://docs.ipfs.tech/install/) +- [fil.one dashboard](https://app.fil.one) — create buckets and API keys - [Security policy](https://github.com/IPFS-Meshkit/meshkit0/blob/main/SECURITY.md) ## Support diff --git a/package-lock.json b/package-lock.json index 0027ecf..2966c08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,21 @@ { "name": "@ipfs-meshkit/meshkit", - "version": "1.0.1", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ipfs-meshkit/meshkit", - "version": "1.0.1", + "version": "1.2.0", "license": "MIT", "workspaces": [ "packages/*", "examples/*" ], "dependencies": { - "kubo-rpc-client": "^7.1.0" + "aws4fetch": "^1.0.20", + "kubo-rpc-client": "^7.1.0", + "multiformats": "^14.0.0" }, "devDependencies": { "@types/node": "^25.9.3", @@ -30,7 +32,7 @@ "name": "meshkit-dummy-app", "version": "1.0.0", "dependencies": { - "@ipfs-meshkit/meshkit": "file:../.." + "@ipfs-meshkit/meshkit": "*" } }, "node_modules/@ampproject/remapping": { @@ -2213,6 +2215,12 @@ "dev": true, "license": "MIT" }, + "node_modules/aws4fetch": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz", + "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==", + "license": "MIT" + }, "node_modules/babel-plugin-syntax-hermes-parser": { "version": "0.36.0", "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.0.tgz", @@ -7995,7 +8003,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@ipfs-meshkit/meshkit": "1.0.1", + "@ipfs-meshkit/meshkit": "1.0.2", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^3.25.76" }, @@ -8011,6 +8019,22 @@ "node": ">=20" } }, + "packages/mcp/node_modules/@ipfs-meshkit/meshkit": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@ipfs-meshkit/meshkit/-/meshkit-1.0.2.tgz", + "integrity": "sha512-vGZnn4aUWS6TDIIetgOCYNBhBAyNS7KqftL3ODZphoUVTzM53hShBuIVLz/aEpRYHl2gpd/KQ78u1/jOyjpfBA==", + "license": "MIT", + "workspaces": [ + "packages/*", + "examples/*" + ], + "dependencies": { + "kubo-rpc-client": "^7.1.0" + }, + "engines": { + "node": ">=20" + } + }, "packages/meshkit": { "name": "@ipfs-meshkit/meshkit-workspace", "version": "1.0.0", diff --git a/package.json b/package.json index 0f0519c..af77db0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ipfs-meshkit/meshkit", - "version": "1.0.1", + "version": "1.2.0", "description": "Node.js SDK for Kubo/IPFS — upload, pin, IPNS, local daemon, and multi-node failover.", "type": "module", "main": "./dist/index.cjs", @@ -8,6 +8,16 @@ "types": "./dist/index.d.ts", "exports": { ".": { + "browser": { + "import": { + "types": "./dist/index.browser.d.ts", + "default": "./dist/index.browser.js" + }, + "require": { + "types": "./dist/index.browser.d.cts", + "default": "./dist/index.browser.cjs" + } + }, "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" @@ -31,6 +41,8 @@ "scripts": { "build": "tsup", "build:mcp": "npm run build && npm run build -w @ipfs-meshkit/mcp", + "publish:meshkit": "npm run build && npm publish", + "publish:mcp": "npm run build:mcp && npm publish -w @ipfs-meshkit/mcp", "clean": "rm -rf dist && npm run clean --workspaces --if-present && find packages -name 'tsconfig.tsbuildinfo' -delete 2>/dev/null || true", "prepublishOnly": "npm run build", "test": "npm run test:unit", @@ -77,13 +89,15 @@ "access": "public" }, "dependencies": { - "kubo-rpc-client": "^7.1.0" + "aws4fetch": "^1.0.20", + "kubo-rpc-client": "^7.1.0", + "multiformats": "^14.0.0" }, "devDependencies": { "@types/node": "^25.9.3", "@vitest/coverage-v8": "^3.2.4", - "typescript": "^6.0.3", "tsup": "^8.5.0", + "typescript": "^6.0.3", "vitest": "^3.2.4" } } diff --git a/packages/core/src/create-client.ts b/packages/core/src/create-client.ts index c058fcf..796f691 100644 --- a/packages/core/src/create-client.ts +++ b/packages/core/src/create-client.ts @@ -9,7 +9,8 @@ import type { IpnsPublishOptions, IpnsResolveOptions, } from './ipns/types.js'; -import type { MeshkitClient, MeshkitConfig } from './types.js'; +import type { MeshkitClient, MeshkitConfig, StoredObject } from './types.js'; +import { MeshkitError } from './types.js'; function concatChunks(chunks: Uint8Array[], totalLength: number): Uint8Array { const result = new Uint8Array(totalLength); @@ -108,6 +109,10 @@ export function createMeshkitClient(config: MeshkitConfig): MeshkitClient { return [...cids]; }, + list() { + throw new MeshkitError('list() is not supported on Kubo — use listPins() instead'); + }, + async healthCheck(): Promise { await ipfs.id(); }, diff --git a/packages/core/src/create-filone-client.ts b/packages/core/src/create-filone-client.ts new file mode 100644 index 0000000..9c9358f --- /dev/null +++ b/packages/core/src/create-filone-client.ts @@ -0,0 +1,228 @@ +import { AwsClient } from 'aws4fetch'; +import { CID } from 'multiformats/cid'; +import { sha256 } from 'multiformats/hashes/sha2'; +import * as raw from 'multiformats/codecs/raw'; +import { MeshkitError } from './types.js'; +import type { MeshkitClient, StoredObject } from './types.js'; + +export interface S3StorageConfig { + accessKeyId: string; + secretAccessKey: string; + bucket: string; + /** + * Any S3-compatible endpoint. + * Examples: + * fil.one: https://us-east-1.s3.fil.one + * Lighthouse: https://gateway.lighthouse.storage + */ + endpoint: string; +} + +/** + * Configuration for a fil.one S3-compatible storage bucket. + * + * Get your access key at https://app.fil.one → API Keys → Create Key. + * The endpoint format is: https://.s3.fil.one + */ +export interface FilOneConfig { + /** fil.one access key ID (format: FHXXXXXXXXXXXXXXXX). */ + accessKeyId: string; + + /** fil.one secret access key. Shown only once at key creation. */ + secretAccessKey: string; + + /** Target bucket name, created in the fil.one dashboard. */ + bucket: string; + + /** + * S3 endpoint for your fil.one region. + * @default "https://eu-west-1.s3.fil.one" + */ + endpoint?: string; +} + +async function computeCid(data: Uint8Array): Promise { + const hash = await sha256.digest(data); + return CID.create(1, raw.code, hash).toString(); +} + +const NOT_SUPPORTED = (op: string) => (): Promise => + Promise.reject( + new MeshkitError( + `${op} is not supported on S3-compatible backends — object stores have no IPFS keystore or pinning layer`, + ), + ); + +/** + * Create a MeshkitClient backed by any S3-compatible object store + * (fil.one, Lighthouse, Filebase, 4EVERLAND, Akave, ChainSafe, …) + * instead of a Kubo node. + * + * Files are stored at // where the CID is computed + * locally from the raw bytes — no IPFS daemon required. + * + * @example + * ```ts + * const client = createS3Client({ + * accessKeyId: process.env.STORAGE_KEY!, + * secretAccessKey: process.env.STORAGE_SECRET!, + * bucket: 'my-bucket', + * endpoint: 'https://', + * }); + * ``` + */ +export function createS3Client(config: S3StorageConfig): MeshkitClient { + const endpoint = config.endpoint.replace(/\/$/, ''); + const { bucket } = config; + + const aws = new AwsClient({ + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + service: 's3', + }); + + function objectUrl(key: string): string { + return `${endpoint}/${bucket}/${key}`; + } + + async function listAllObjects(): Promise<{ key: string; size: number; lastModified: Date | undefined }[]> { + const results: { key: string; size: number; lastModified: Date | undefined }[] = []; + let continuationToken: string | undefined; + + do { + const url = new URL(`${endpoint}/${bucket}`); + url.searchParams.set('list-type', '2'); + if (continuationToken) { + url.searchParams.set('continuation-token', continuationToken); + } + + const res = await aws.fetch(url.toString(), { method: 'GET' }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new MeshkitError( + `S3 list failed [${res.status}]: ${body || res.statusText}`, + ); + } + + const xml = await res.text(); + + // Parse entries from S3 ListObjectsV2 XML response + const contentsMatches = xml.matchAll(/([\s\S]*?)<\/Contents>/g); + for (const match of contentsMatches) { + const block = match[1]; + const key = block.match(/(.*?)<\/Key>/)?.[1]; + const sizeStr = block.match(/(.*?)<\/Size>/)?.[1]; + const lastModStr = block.match(/(.*?)<\/LastModified>/)?.[1]; + + if (!key) continue; + // Skip non-CID keys (anything containing a dot, e.g. manifest.json) + if (key.includes('.')) continue; + + results.push({ + key, + size: sizeStr ? parseInt(sizeStr, 10) : 0, + lastModified: lastModStr ? new Date(lastModStr) : undefined, + }); + } + + const isTruncated = /true<\/IsTruncated>/.test(xml); + const nextToken = xml.match(/(.*?)<\/NextContinuationToken>/)?.[1]; + continuationToken = isTruncated ? nextToken : undefined; + } while (continuationToken); + + return results; + } + + return { + async upload(data: Uint8Array): Promise { + const cid = await computeCid(data); + const res = await aws.fetch(objectUrl(cid), { + method: 'PUT', + headers: { 'Content-Type': 'application/octet-stream' }, + body: data.buffer as BodyInit, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new MeshkitError( + `S3 upload failed [${res.status}]: ${body || res.statusText}`, + ); + } + + return cid; + }, + + async retrieve(cid: string): Promise { + const res = await aws.fetch(objectUrl(cid), { method: 'GET' }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new MeshkitError( + `S3 retrieve failed [${res.status}] for CID ${cid}: ${body || res.statusText}`, + ); + } + + return new Uint8Array(await res.arrayBuffer()); + }, + + async pin(_cid: string): Promise {}, + + async listPins(): Promise { + const objects = await listAllObjects(); + return objects.map((o) => o.key); + }, + + async list(): Promise { + const objects = await listAllObjects(); + return objects.map((o) => ({ + cid: o.key, + size: o.size, + uploadedAt: o.lastModified?.toISOString() ?? '', + })); + }, + + publishName: NOT_SUPPORTED('publishName'), + resolveName: NOT_SUPPORTED('resolveName'), + resolveAndRetrieve: NOT_SUPPORTED('resolveAndRetrieve'), + generateKey: NOT_SUPPORTED('generateKey'), + listKeys: NOT_SUPPORTED('listKeys'), + + async healthCheck(): Promise { + const res = await aws.fetch(`${endpoint}/${bucket}`, { method: 'HEAD' }); + if (!res.ok && res.status !== 404) { + throw new MeshkitError( + `S3 health check failed [${res.status}]: verify accessKeyId, secretAccessKey, and bucket name`, + ); + } + }, + }; +} + +/** + * Create a MeshkitClient backed by fil.one (Filecoin-native S3-compatible + * object storage). This is a thin preset over `createS3Client` — the only + * difference is that `endpoint` defaults to `https://eu-west-1.s3.fil.one`. + * + * @example + * ```ts + * import { createFilOneClient } from '@ipfs-meshkit/meshkit'; + * + * const client = createFilOneClient({ + * accessKeyId: process.env.FIL_ACCESS_KEY!, + * secretAccessKey: process.env.FIL_SECRET_KEY!, + * bucket: 'my-invoices', + * }); + * + * const cid = await client.upload(data); + * const bytes = await client.retrieve(cid); + * ``` + */ +export function createFilOneClient(config: FilOneConfig): MeshkitClient { + return createS3Client({ + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + bucket: config.bucket, + endpoint: config.endpoint ?? 'https://eu-west-1.s3.fil.one', + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 71b855d..bcd2a94 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ export type { MeshkitClient, MeshkitConfig, MeshkitInitOptions, + StoredObject, IpnsDuration, IpnsKey, IpnsKeyGenOptions, @@ -14,6 +15,8 @@ export { MeshkitError } from './types.js'; export { Meshkit } from './meshkit.js'; export { createMeshkitClient } from './create-client.js'; +export { createS3Client, createFilOneClient } from './create-filone-client.js'; +export type { S3StorageConfig, FilOneConfig } from './create-filone-client.js'; export { IPNS_TTL_DEFAULT, IPNS_TTL_FAST } from './ipns/constants.js'; export { extractCidFromPath, toIpfsPath, toIpnsPath } from './ipns/paths.js'; diff --git a/packages/core/src/meshkit.ts b/packages/core/src/meshkit.ts index 1f18692..ab61972 100644 --- a/packages/core/src/meshkit.ts +++ b/packages/core/src/meshkit.ts @@ -11,6 +11,7 @@ import type { Meshkit as MeshkitFacade, MeshkitClient, MeshkitInitOptions, + StoredObject, } from './types.js'; export class Meshkit implements MeshkitFacade { @@ -92,4 +93,8 @@ export class Meshkit implements MeshkitFacade { listPins() { return withPrimary(this.clients, (client) => client.listPins()); } + + list(): Promise { + throw new MeshkitError('list() is not supported on Kubo — use listPins() instead'); + } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 80b368b..45e3308 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -39,6 +39,15 @@ export interface MeshkitConfig { headers?: Record; } +export interface StoredObject { + /** Content-addressed key (CID or custom key) identifying the object. */ + cid: string; + /** Size of the object in bytes. */ + size: number; + /** ISO 8601 timestamp of when the object was stored. */ + uploadedAt: string; +} + export interface MeshkitClient { /** Upload raw bytes to the connected IPFS node. Returns the CID string. */ upload(data: Uint8Array): Promise; @@ -84,6 +93,13 @@ export interface MeshkitClient { /** List all pinned CIDs on the connected node. */ listPins(): Promise; + /** + * List all stored objects with metadata. + * Supported on S3-compatible backends (fil.one, Lighthouse, etc.). + * Throws MeshkitError on Kubo — use listPins() instead. + */ + list(): Promise; + /** Confirm the node's RPC API is reachable. Throws if it is not. */ healthCheck(): Promise; } @@ -142,6 +158,12 @@ export interface Meshkit { /** List all pinned CIDs on the primary node. */ listPins(): Promise; + /** + * List all stored objects with metadata. + * Throws MeshkitError on Kubo — use listPins() instead. + */ + list(): Promise; + /** Nodes that passed the health check at init, in priority order. */ readonly activeNodes: readonly string[]; } diff --git a/packages/core/test/create-filone-client.test.ts b/packages/core/test/create-filone-client.test.ts new file mode 100644 index 0000000..c14316d --- /dev/null +++ b/packages/core/test/create-filone-client.test.ts @@ -0,0 +1,311 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createFilOneClient, createS3Client } from '../src/create-filone-client.js'; +import { createMeshkitClient } from '../src/create-client.js'; +import { MeshkitError } from '../src/types.js'; + +const CONFIG = { + accessKeyId: 'FHTESTACCESSKEYID', + secretAccessKey: 'test-secret-key-value', + bucket: 'test-bucket', + endpoint: 'https://eu-west-1.s3.fil.one', +}; + +function makeResponse(status: number, body: BodyInit = ''): Response { + return new Response(body, { status }); +} + +function makeListXml(objects: { key: string; size: number; lastModified: string }[], truncated = false, nextToken?: string): string { + const contents = objects.map(o => ` + + ${o.key} + ${o.size} + ${o.lastModified} + `).join(''); + + return ` + + ${truncated} + ${contents} + ${nextToken ? `${nextToken}` : ''} + `; +} + +describe('createS3Client / createFilOneClient', () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + // --------------------------------------------------------------------------- + // upload + // --------------------------------------------------------------------------- + describe('upload', () => { + it('returns the CIDv1 of the uploaded bytes', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeResponse(200))); + const client = createFilOneClient(CONFIG); + const cid = await client.upload(new TextEncoder().encode('hello filecoin')); + expect(cid).toMatch(/^b[a-z2-7]+$/); + }); + + it('sends a signed PUT request to the correct URL', async () => { + const fetchMock = vi.fn().mockResolvedValue(makeResponse(200)); + vi.stubGlobal('fetch', fetchMock); + const client = createFilOneClient(CONFIG); + const data = new TextEncoder().encode('invoice data'); + const cid = await client.upload(data); + const req = fetchMock.mock.calls[0][0] as Request; + expect(req.url).toBe(`https://eu-west-1.s3.fil.one/test-bucket/${cid}`); + expect(req.method).toBe('PUT'); + }); + + it('throws MeshkitError when the server returns a non-2xx status', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeResponse(403, 'AccessDenied'))); + const client = createFilOneClient(CONFIG); + await expect(client.upload(new TextEncoder().encode('data'))).rejects.toBeInstanceOf(MeshkitError); + }); + + it('produces identical CIDs for identical content', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeResponse(200))); + const client = createFilOneClient(CONFIG); + const data = new TextEncoder().encode('same content'); + expect(await client.upload(data)).toBe(await client.upload(data)); + }); + + it('produces different CIDs for different content', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeResponse(200))); + const client = createFilOneClient(CONFIG); + const cid1 = await client.upload(new TextEncoder().encode('invoice-001')); + const cid2 = await client.upload(new TextEncoder().encode('invoice-002')); + expect(cid1).not.toBe(cid2); + }); + }); + + // --------------------------------------------------------------------------- + // retrieve + // --------------------------------------------------------------------------- + describe('retrieve', () => { + it('returns the bytes stored at the given CID', async () => { + const original = new TextEncoder().encode('invoice payload'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(original.buffer))); + const client = createFilOneClient(CONFIG); + const result = await client.retrieve('bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku'); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toEqual(original); + }); + + it('sends a signed GET request to the correct URL', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(new Uint8Array(4).buffer)); + vi.stubGlobal('fetch', fetchMock); + const client = createFilOneClient(CONFIG); + const cid = 'bafkreiabcdef'; + await client.retrieve(cid); + const req = fetchMock.mock.calls[0][0] as Request; + expect(req.url).toBe(`https://eu-west-1.s3.fil.one/test-bucket/${cid}`); + expect(req.method).toBe('GET'); + }); + + it('throws MeshkitError on 404', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeResponse(404, 'NoSuchKey'))); + const client = createFilOneClient(CONFIG); + await expect(client.retrieve('bafkreimissing')).rejects.toBeInstanceOf(MeshkitError); + }); + }); + + // --------------------------------------------------------------------------- + // pin + // --------------------------------------------------------------------------- + describe('pin', () => { + it('silently succeeds (no-op)', async () => { + const client = createFilOneClient(CONFIG); + await expect(client.pin('bafkreiabcdef')).resolves.toBeUndefined(); + }); + }); + + // --------------------------------------------------------------------------- + // list + // --------------------------------------------------------------------------- + describe('list', () => { + it('returns StoredObjects for all CID keys with correct metadata', async () => { + const enc = new TextEncoder(); + const data1 = enc.encode('invoice-alpha'); + const data2 = enc.encode('invoice-beta'); + + // First two calls: PUTs for upload; third call: ListObjectsV2 + const uploadMock = vi.fn().mockResolvedValue(makeResponse(200)); + vi.stubGlobal('fetch', uploadMock); + + const client = createFilOneClient(CONFIG); + const cid1 = await client.upload(data1); + const cid2 = await client.upload(data2); + + const listXml = makeListXml([ + { key: cid1, size: data1.byteLength, lastModified: '2026-07-22T10:00:00.000Z' }, + { key: cid2, size: data2.byteLength, lastModified: '2026-07-22T11:00:00.000Z' }, + ]); + uploadMock.mockResolvedValueOnce(makeResponse(200, listXml)); + + const objects = await client.list(); + + expect(objects).toHaveLength(2); + const keys = objects.map((o) => o.cid); + expect(keys).toContain(cid1); + expect(keys).toContain(cid2); + + const obj1 = objects.find((o) => o.cid === cid1)!; + expect(obj1.size).toBe(data1.byteLength); + expect(() => new Date(obj1.uploadedAt)).not.toThrow(); + expect(new Date(obj1.uploadedAt).toISOString()).toBe(obj1.uploadedAt); + }); + + it('excludes keys that contain a dot (e.g. manifest.json)', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + makeResponse(200, makeListXml([ + { key: 'bafkreigoodcid', size: 42, lastModified: '2026-07-22T10:00:00.000Z' }, + { key: 'manifest.json', size: 128, lastModified: '2026-07-22T09:00:00.000Z' }, + ])) + )); + const client = createFilOneClient(CONFIG); + const objects = await client.list(); + expect(objects).toHaveLength(1); + expect(objects[0].cid).toBe('bafkreigoodcid'); + }); + + it('handles paginated results via IsTruncated + NextContinuationToken', async () => { + const page1 = makeListXml( + [{ key: 'bafkreipage1', size: 10, lastModified: '2026-07-22T10:00:00.000Z' }], + true, + 'tok-abc', + ); + const page2 = makeListXml( + [{ key: 'bafkreipage2', size: 20, lastModified: '2026-07-22T11:00:00.000Z' }], + false, + ); + + const fetchMock = vi.fn() + .mockResolvedValueOnce(makeResponse(200, page1)) + .mockResolvedValueOnce(makeResponse(200, page2)); + vi.stubGlobal('fetch', fetchMock); + + const client = createFilOneClient(CONFIG); + const objects = await client.list(); + expect(objects).toHaveLength(2); + expect(objects.map((o) => o.cid)).toEqual(['bafkreipage1', 'bafkreipage2']); + }); + }); + + // --------------------------------------------------------------------------- + // listPins + // --------------------------------------------------------------------------- + describe('listPins', () => { + it('returns the same CIDs as list().map(o => o.cid)', async () => { + const cids = ['bafkreiaaa', 'bafkreibbb']; + const listXml = makeListXml( + cids.map((key) => ({ key, size: 10, lastModified: '2026-07-22T10:00:00.000Z' })) + ); + + const fetchMock = vi.fn() + .mockResolvedValueOnce(makeResponse(200, listXml)) // listPins call + .mockResolvedValueOnce(makeResponse(200, listXml)); // list call + vi.stubGlobal('fetch', fetchMock); + + const client = createFilOneClient(CONFIG); + const pins = await client.listPins(); + const listCids = (await client.list()).map((o) => o.cid); + + expect(pins).toEqual(listCids); + }); + }); + + // --------------------------------------------------------------------------- + // healthCheck + // --------------------------------------------------------------------------- + describe('healthCheck', () => { + it('resolves when the bucket HEAD returns 200', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeResponse(200))); + await expect(createFilOneClient(CONFIG).healthCheck()).resolves.toBeUndefined(); + }); + + it('resolves when the bucket HEAD returns 404', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeResponse(404))); + await expect(createFilOneClient(CONFIG).healthCheck()).resolves.toBeUndefined(); + }); + + it('throws MeshkitError on 403 (bad credentials)', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeResponse(403))); + await expect(createFilOneClient(CONFIG).healthCheck()).rejects.toBeInstanceOf(MeshkitError); + }); + }); + + // --------------------------------------------------------------------------- + // unsupported IPNS / key operations + // --------------------------------------------------------------------------- + describe('unsupported IPNS / key operations', () => { + it.each([ + ['publishName', (c: ReturnType) => c.publishName('/ipfs/abc')], + ['resolveName', (c: ReturnType) => c.resolveName('k51abc')], + ['resolveAndRetrieve', (c: ReturnType) => c.resolveAndRetrieve('k51abc')], + ['generateKey', (c: ReturnType) => c.generateKey('my-key')], + ['listKeys', (c: ReturnType) => c.listKeys()], + ])('%s throws MeshkitError', async (_name, call) => { + const client = createFilOneClient(CONFIG); + await expect(call(client)).rejects.toBeInstanceOf(MeshkitError); + }); + }); + + // --------------------------------------------------------------------------- + // list() on Kubo client throws + // --------------------------------------------------------------------------- + describe('list() on Kubo client', () => { + it('throws MeshkitError', () => { + const client = createMeshkitClient({ apiUrl: 'http://127.0.0.1:5001' }); + expect(() => client.list()).toThrow(MeshkitError); + }); + }); + + // --------------------------------------------------------------------------- + // createFilOneClient defaults + // --------------------------------------------------------------------------- + describe('createFilOneClient defaults', () => { + it('uses eu-west-1.s3.fil.one when no endpoint is provided', async () => { + const fetchMock = vi.fn().mockResolvedValue(makeResponse(200)); + vi.stubGlobal('fetch', fetchMock); + const client = createFilOneClient({ + accessKeyId: CONFIG.accessKeyId, + secretAccessKey: CONFIG.secretAccessKey, + bucket: CONFIG.bucket, + }); + const cid = await client.upload(new TextEncoder().encode('default endpoint test')); + const req = fetchMock.mock.calls[0][0] as Request; + expect(req.url).toContain('eu-west-1.s3.fil.one'); + expect(req.url).toContain(cid); + }); + + it('strips a trailing slash from a custom endpoint', async () => { + const fetchMock = vi.fn().mockResolvedValue(makeResponse(200)); + vi.stubGlobal('fetch', fetchMock); + const client = createFilOneClient({ ...CONFIG, endpoint: 'https://eu-west-1.s3.fil.one/' }); + await client.upload(new TextEncoder().encode('trailing slash')); + const req = fetchMock.mock.calls[0][0] as Request; + expect(req.url).not.toContain('//test-bucket'); + }); + }); + + // --------------------------------------------------------------------------- + // createS3Client with a custom endpoint + // --------------------------------------------------------------------------- + describe('createS3Client', () => { + it('uses the provided custom endpoint', async () => { + const fetchMock = vi.fn().mockResolvedValue(makeResponse(200)); + vi.stubGlobal('fetch', fetchMock); + const client = createS3Client({ + accessKeyId: 'key', + secretAccessKey: 'secret', + bucket: 'my-bucket', + endpoint: 'https://gateway.lighthouse.storage', + }); + const cid = await client.upload(new TextEncoder().encode('lighthouse test')); + const req = fetchMock.mock.calls[0][0] as Request; + expect(req.url).toContain('gateway.lighthouse.storage'); + expect(req.url).toContain(cid); + }); + }); +}); diff --git a/packages/mcp/LICENSE b/packages/mcp/LICENSE new file mode 100644 index 0000000..e6ca12e --- /dev/null +++ b/packages/mcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 IPFS Meshkit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/mcp/README.md b/packages/mcp/README.md index d697d1f..b652dc2 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -5,8 +5,11 @@ MCP (Model Context Protocol) server for [IPFS Meshkit](https://github.com/IPFS-M ## Prerequisites - **Node.js 20+** +- **`@ipfs-meshkit/meshkit` ≥ 1.0.2** (installed automatically as a dependency) - A running [Kubo](https://docs.ipfs.tech/install/) node with RPC API reachable (default: `http://127.0.0.1:5001`), **or** set `MESHKIT_LOCAL_NODE=true` to start/attach to Kubo automatically (requires `ipfs` on `PATH`) +> **Publish order:** release `@ipfs-meshkit/meshkit@1.0.2` before `@ipfs-meshkit/mcp@1.0.0`. MCP uses `meshkit.listPins()`, added in meshkit 1.0.2. + ## Quick start ```bash diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 750a3fa..f4c49eb 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -8,7 +8,8 @@ }, "files": [ "dist", - "README.md" + "README.md", + "LICENSE" ], "scripts": { "build": "tsup", @@ -20,6 +21,10 @@ "url": "git+https://github.com/IPFS-Meshkit/meshkit0.git", "directory": "packages/mcp" }, + "bugs": { + "url": "https://github.com/IPFS-Meshkit/meshkit0/issues" + }, + "homepage": "https://github.com/IPFS-Meshkit/meshkit0#readme", "keywords": [ "ipfs", "kubo", @@ -37,7 +42,7 @@ "access": "public" }, "dependencies": { - "@ipfs-meshkit/meshkit": "1.0.1", + "@ipfs-meshkit/meshkit": "1.0.2", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^3.25.76" }, diff --git a/packages/meshkit/src/index.browser.ts b/packages/meshkit/src/index.browser.ts new file mode 100644 index 0000000..0d32fe1 --- /dev/null +++ b/packages/meshkit/src/index.browser.ts @@ -0,0 +1,27 @@ +// Browser / Capacitor / React Native entry — omits @ipfs-meshkit/node +// (child_process, path) and the Node.js-only init / shutdown helpers. +export type { + MeshkitClient, + MeshkitConfig, + MeshkitInitOptions, + StoredObject, + IpnsDuration, + IpnsKey, + IpnsKeyGenOptions, + IpnsPublishOptions, + IpnsPublishResult, + IpnsResolveOptions, +} from '@ipfs-meshkit/core'; +export { + MeshkitError, + Meshkit, + createMeshkitClient, + createS3Client, + createFilOneClient, + IPNS_TTL_DEFAULT, + IPNS_TTL_FAST, + extractCidFromPath, + toIpfsPath, + toIpnsPath, +} from '@ipfs-meshkit/core'; +export type { S3StorageConfig, FilOneConfig } from '@ipfs-meshkit/core'; diff --git a/packages/meshkit/src/index.ts b/packages/meshkit/src/index.ts index ad80d7a..46cf55f 100644 --- a/packages/meshkit/src/index.ts +++ b/packages/meshkit/src/index.ts @@ -2,6 +2,7 @@ export type { MeshkitClient, MeshkitConfig, MeshkitInitOptions, + StoredObject, IpnsDuration, IpnsKey, IpnsKeyGenOptions, @@ -13,12 +14,15 @@ export { MeshkitError, Meshkit, createMeshkitClient, + createS3Client, + createFilOneClient, IPNS_TTL_DEFAULT, IPNS_TTL_FAST, extractCidFromPath, toIpfsPath, toIpnsPath, } from '@ipfs-meshkit/core'; +export type { S3StorageConfig, FilOneConfig } from '@ipfs-meshkit/core'; export type { IPFSNodeHandle, diff --git a/tsup.config.ts b/tsup.config.ts index 3a314db..564f7fe 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -4,32 +4,53 @@ import { defineConfig } from 'tsup'; const root = path.dirname(fileURLToPath(import.meta.url)); -export default defineConfig({ - entry: [path.join(root, 'packages/meshkit/src/index.ts')], - format: ['esm', 'cjs'], - outDir: 'dist', - clean: true, - sourcemap: true, - target: 'es2022', - esbuildOptions(options) { - options.alias = { - '@ipfs-meshkit/core': path.join(root, 'packages/core/src/index.ts'), - '@ipfs-meshkit/node': path.join(root, 'packages/node/src/index.ts'), - }; +const sharedAlias = { + '@ipfs-meshkit/core': path.join(root, 'packages/core/src/index.ts'), + '@ipfs-meshkit/node': path.join(root, 'packages/node/src/index.ts'), +}; + +const sharedDts = { + compilerOptions: { + composite: false, + incremental: false, + ignoreDeprecations: '6.0', + paths: { + '@ipfs-meshkit/core': ['./packages/core/src/index.ts'], + '@ipfs-meshkit/node': ['./packages/node/src/index.ts'], + }, + baseUrl: '.', + }, +}; + +export default defineConfig([ + // Node.js build — full surface including @ipfs-meshkit/node (child_process, path). + { + entry: { index: path.join(root, 'packages/meshkit/src/index.ts') }, + format: ['esm', 'cjs'], + outDir: 'dist', + clean: true, + sourcemap: true, + target: 'es2022', + esbuildOptions(options) { + options.alias = sharedAlias; + }, + dts: sharedDts, + noExternal: [/^@ipfs-meshkit\//], + external: ['kubo-rpc-client', 'aws4fetch', 'multiformats'], }, - dts: { - compilerOptions: { - composite: false, - incremental: false, - ignoreDeprecations: '6.0', - paths: { - '@ipfs-meshkit/core': ['./packages/core/src/index.ts'], - '@ipfs-meshkit/node': ['./packages/node/src/index.ts'], - }, - baseUrl: '.', + // Browser build — only @ipfs-meshkit/core; no Node.js built-ins. + { + entry: { 'index.browser': path.join(root, 'packages/meshkit/src/index.browser.ts') }, + format: ['esm', 'cjs'], + outDir: 'dist', + clean: false, + sourcemap: true, + target: 'es2022', + esbuildOptions(options) { + options.alias = sharedAlias; }, + dts: sharedDts, + noExternal: [/^@ipfs-meshkit\//], + external: ['kubo-rpc-client', 'aws4fetch', 'multiformats'], }, - // Bundle workspace packages into one publishable artifact. - noExternal: [/^@ipfs-meshkit\//], - external: ['kubo-rpc-client'], -}); +]);