@storacha/client
Version:
Client for the storacha.network w3up api
522 lines (521 loc) • 22.6 kB
JavaScript
import { uploadFile, uploadDirectory, uploadCAR, Receipt, } from '@storacha/upload-client';
import { Access as AccessCapabilities, SpaceBlob as BlobCapabilities, SpaceIndex as IndexCapabilities, Upload as UploadCapabilities, Filecoin as FilecoinCapabilities, Space as SpaceCapabilities, } from '@storacha/capabilities';
import * as DIDMailto from '@storacha/did-mailto';
import { Base } from './base.js';
import * as Account from './account.js';
import { Space } from './space.js';
import { AgentDelegation } from './delegation.js';
import { BlobClient } from './capability/blob.js';
import { IndexClient } from './capability/index.js';
import { UploadClient } from './capability/upload.js';
import { SpaceClient } from './capability/space.js';
import { SubscriptionClient } from './capability/subscription.js';
import { UsageClient } from './capability/usage.js';
import { AccessClient } from './capability/access.js';
import { PlanClient } from './capability/plan.js';
import { FilecoinClient } from './capability/filecoin.js';
import { CouponAPI } from './coupon.js';
export * as Access from './capability/access.js';
import * as Result from './result.js';
export { AccessClient, BlobClient, FilecoinClient, IndexClient, PlanClient, SpaceClient, SubscriptionClient, UploadClient, UsageClient, };
export class Client extends Base {
/**
* @param {import('@storacha/access').AgentData} agentData
* @param {object} [options]
* @param {import('./types.js').ServiceConf} [options.serviceConf]
* @param {URL} [options.receiptsEndpoint]
*/
constructor(agentData, options) {
super(agentData, options);
this.capability = {
access: new AccessClient(agentData, options),
filecoin: new FilecoinClient(agentData, options),
index: new IndexClient(agentData, options),
plan: new PlanClient(agentData, options),
space: new SpaceClient(agentData, options),
blob: new BlobClient(agentData, options),
subscription: new SubscriptionClient(agentData, options),
upload: new UploadClient(agentData, options),
usage: new UsageClient(agentData, options),
};
this.coupon = new CouponAPI(agentData, options);
}
did() {
return this._agent.did();
}
/* c8 ignore start */
/**
* @deprecated - Use client.login instead.
*
* Authorize the current agent to use capabilities granted to the passed
* email account.
*
* @param {`${string}@${string}`} email
* @param {object} [options]
* @param {AbortSignal} [options.signal]
* @param {Iterable<{ can: import('./types.js').Ability }>} [options.capabilities]
*/
async authorize(email, options) {
await this.capability.access.authorize(email, options);
}
/* c8 ignore stop */
/**
* @param {Account.EmailAddress} email
* @param {object} [options]
* @param {AbortSignal} [options.signal]
* @param {import('@storacha/client/types').AppName} [options.appName]
* @param {import('@storacha/client/types').SSORequestParams} [options.sso] - SSO authentication request (all fields required if provided)
*/
async login(email, options = {}) {
const account = Result.unwrap(await Account.login(this, email, options));
Result.unwrap(await account.save());
return account;
}
/**
* List all accounts that agent has stored access to.
*
* @returns {Record<DIDMailto.DidMailto, Account.Account>} A dictionary with `did:mailto` as keys and `Account` instances as values.
*/
accounts() {
return Account.list(this);
}
/**
* Uploads a file to the service and returns the root data CID for the
* generated DAG.
*
* Required delegated capabilities:
* - `filecoin/offer`
* - `space/blob/add`
* - `space/index/add`
* - `upload/add`
*
* @param {import('./types.js').BlobLike} file - File data.
* @param {import('./types.js').UploadFileOptions} [options]
*/
async uploadFile(file, options = {}) {
const conf = await this._invocationConfig([
BlobCapabilities.add.can,
IndexCapabilities.add.can,
FilecoinCapabilities.offer.can,
UploadCapabilities.add.can,
]);
options = {
receiptsEndpoint: this._receiptsEndpoint.toString(),
connection: this._serviceConf.upload,
...options,
};
return uploadFile(conf, file, options);
}
/**
* Uploads a directory of files to the service and returns the root data CID
* for the generated DAG. All files are added to a container directory, with
* paths in the file names preserved.
*
* Required delegated capabilities:
* - `filecoin/offer`
* - `space/blob/add`
* - `space/index/add`
* - `upload/add`
*
* @param {import('./types.js').FileLike[]} files - File data.
* @param {import('./types.js').UploadDirectoryOptions} [options]
*/
async uploadDirectory(files, options = {}) {
const conf = await this._invocationConfig([
BlobCapabilities.add.can,
IndexCapabilities.add.can,
FilecoinCapabilities.offer.can,
UploadCapabilities.add.can,
]);
options = {
receiptsEndpoint: this._receiptsEndpoint.toString(),
connection: this._serviceConf.upload,
...options,
};
return uploadDirectory(conf, files, options);
}
/**
* Uploads a CAR file to the service.
*
* The difference between this function and `capability.blob.add` is that
* the CAR file is automatically sharded, an index is generated, uploaded and
* registered (see `capability.index.add`) and finally an an "upload" is
* registered, linking the individual shards (see `capability.upload.add`).
*
* Use the `onShardStored` callback to obtain the CIDs of the CAR file shards.
*
* Required delegated capabilities:
* - `filecoin/offer`
* - `space/blob/add`
* - `space/index/add`
* - `upload/add`
*
* @param {import('./types.js').BlobLike} car - CAR file.
* @param {import('./types.js').UploadOptions} [options]
*/
async uploadCAR(car, options = {}) {
const conf = await this._invocationConfig([
BlobCapabilities.add.can,
IndexCapabilities.add.can,
FilecoinCapabilities.offer.can,
UploadCapabilities.add.can,
]);
options = {
receiptsEndpoint: this._receiptsEndpoint.toString(),
connection: this._serviceConf.upload,
...options,
};
return uploadCAR(conf, car, options);
}
/**
* Get a receipt for an executed task by its CID.
*
* @template {import('./types.js').Capability} C
* @template {Record<string, any>} S
* @param {import('./types.js').UCANLink<[C]>} taskCid
* @param {import('./types.js').ReceiptGetOptions<S> & import('./types.js').Retryable} [options]
* @returns {Promise<import('./types.js').InferReceipt<C, S>>}
*/
async getReceipt(taskCid, options) {
return Receipt.poll(taskCid, {
endpoint: new URL(this._receiptsEndpoint),
...options,
});
}
/**
* Return the default provider.
*/
defaultProvider() {
return this._agent.connection.id.did();
}
/**
* The current space.
*/
currentSpace() {
const agent = this._agent;
const id = agent.currentSpace();
if (!id)
return;
const meta = agent.spaces.get(id);
return new Space({ id, meta, agent });
}
/**
* Use a specific space.
*
* @param {import('./types.js').DID} did
*/
async setCurrentSpace(did) {
await this._agent.setCurrentSpace(/** @type {`did:key:${string}`} */ (did));
}
/**
* Spaces available to this agent.
*/
spaces() {
return [...this._agent.spaces].map(([id, meta]) => {
// @ts-expect-error id is not did:key
return new Space({ id, meta, agent: this._agent });
});
}
/**
* Creates a new space with a given name.
* If an account is not provided, the space is created without any delegation and is not saved, hence it is a temporary space.
* When an account is provided in the options argument, then it creates a delegated recovery account
* by provisioning the space, saving it and then delegating access to the recovery account.
* In addition, it authorizes the listed Gateway Services to serve content from the created space.
* It is done by delegating the `space/content/serve/*` capability to the Gateway Service.
* User can skip the Gateway authorization by setting the `skipGatewayAuthorization` option to `true`.
* If no gateways are specified or the `skipGatewayAuthorization` flag is not set, the client will automatically grant access
* to the Storacha Gateway by default (https://w3s.link/).
*
* @typedef {import('./types.js').ConnectionView<import('./types.js').ContentServeService>} ConnectionView
*
* @typedef {object} SpaceCreateOptions
* @property {Account.Account} [account] - The account configured as the recovery account for the space.
* @property {Array<ConnectionView>} [authorizeGatewayServices] - The DID Key or DID Web of the Gateway to authorize to serve content from the created space.
* @property {boolean} [skipGatewayAuthorization] - Whether to skip the Gateway authorization. It means that the content of the space will not be served by any Gateway.
* @property {import('@storacha/access').SpaceAccessType} [access] - Access type for the space - determines client-side encryption behavior.
*
* @param {string} name - The name of the space to create.
* @param {SpaceCreateOptions} [options] - Options for the space creation.
* @returns {Promise<import("./space.js").OwnedSpace>} The created space owned by the agent.
*/
async createSpace(name, options = {}) {
// Save the space to authorize the client to use the space
const { access, account, skipGatewayAuthorization, authorizeGatewayServices, } = options;
const space = await this._agent.createSpace(name, { access });
if (account) {
// Provision the account with the space
const provisionResult = await account.provision(space.did());
if (provisionResult.error) {
throw new Error(`failed to provision account: ${provisionResult.error.message}`, { cause: provisionResult.error });
}
// Save the space to authorize the client to use the space
await space.save();
// Create a recovery for the account
const recovery = await space.createRecovery(account.did());
// Delegate space access to the recovery
const delegationResult = await this.capability.access.delegate({
space: space.did(),
delegations: [recovery],
});
if (delegationResult.error) {
// c8 ignore next 4
throw new Error(`failed to authorize recovery account: ${delegationResult.error.message}`, { cause: delegationResult.error });
}
}
// Authorize the listed Gateway Services to serve content from the created space
if (skipGatewayAuthorization !== true) {
let gatewayServices = authorizeGatewayServices;
if (!gatewayServices || gatewayServices.length === 0) {
// If no Gateway Services are provided, authorize the Storacha Gateway Service
gatewayServices = [this._serviceConf.gateway];
}
// Save the space to authorize the client to use the space
await space.save();
for (const serviceConnection of gatewayServices) {
await authorizeContentServe(this, space, serviceConnection);
}
}
return space;
}
/**
* Share an existing space with another Storacha account via email address delegation.
* Delegates access to the space to the specified email account with the following permissions:
* - space/* - for managing space metadata
* - blob/* - for managing blobs
* - store/* - for managing stores
* - upload/*- for registering uploads
* - access/* - for re-delegating access to other devices
* - filecoin/* - for submitting to the filecoin pipeline
* - usage/* - for querying usage
* The default expiration is set to infinity.
*
* @typedef {object} ShareOptions
* @property {import('./types.js').ServiceAbility[]} abilities - Abilities to delegate to the delegate account.
* @property {number} expiration - Expiration time in seconds.
* @param {import("./types.js").EmailAddress} delegateEmail - Email of the account to share the space with.
* @param {import('./types.js').SpaceDID} spaceDID - The DID of the space to share.
* @param {ShareOptions} [options] - Options for the delegation.
*
* @returns {Promise<import('./delegation.js').AgentDelegation<any>>} Resolves with the AgentDelegation instance once the space is successfully shared.
* @throws {Error} - Throws an error if there is an issue delegating access to the space.
*/
async shareSpace(delegateEmail, spaceDID, options = {
abilities: [
'space/*',
'store/*',
'upload/*',
'access/*',
'usage/*',
'filecoin/*',
],
expiration: Infinity,
}) {
const { abilities, ...restOptions } = options;
const currentSpace = this.agent.currentSpace();
try {
// Make sure the agent is using the shared space before delegating
await this.agent.setCurrentSpace(spaceDID);
// Delegate capabilities to the delegate account to access the **current space**
const { root, blocks } = await this.agent.delegate({
...restOptions,
abilities,
audience: {
did: () => DIDMailto.fromEmail(DIDMailto.email(delegateEmail)),
},
// @ts-expect-error audienceMeta is not defined in ShareOptions
audienceMeta: options.audienceMeta ?? {},
});
const delegation = new AgentDelegation(root, blocks, {
audience: delegateEmail,
});
const sharingResult = await this.capability.access.delegate({
space: spaceDID,
delegations: [delegation],
});
if (sharingResult.error) {
throw new Error(`failed to share space with ${delegateEmail}: ${sharingResult.error.message}`, {
cause: sharingResult.error,
});
}
return delegation;
}
finally {
// Reset to the original space if it was different
if (currentSpace && currentSpace !== spaceDID) {
await this.agent.setCurrentSpace(currentSpace);
}
}
}
/* c8 ignore stop */
/**
* Add a space from a received proof.
*
* @param {import('./types.js').Delegation} proof
*/
async addSpace(proof) {
return await this._agent.importSpaceFromDelegation(proof);
}
/**
* Get all the proofs matching the capabilities.
*
* Proofs are delegations with an _audience_ matching the agent DID.
*
* @param {import('./types.js').Capability[]} [caps] - Capabilities to
* filter by. Empty or undefined caps with return all the proofs.
*/
proofs(caps) {
return this._agent.proofs(caps);
}
/**
* Add a proof to the agent. Proofs are delegations with an _audience_
* matching the agent DID.
*
* @param {import('./types.js').Delegation} proof
*/
async addProof(proof) {
await this._agent.addProof(proof);
}
/**
* Get delegations created by the agent for others.
*
* @param {import('./types.js').Capability[]} [caps] - Capabilities to
* filter by. Empty or undefined caps with return all the delegations.
*/
delegations(caps) {
const delegations = [];
for (const { delegation, meta } of this._agent.delegationsWithMeta(caps)) {
delegations.push(new AgentDelegation(delegation.root, delegation.blocks, meta));
}
return delegations;
}
/**
* Create a delegation to the passed audience for the given abilities with
* the _current_ space as the resource.
*
* @param {import('./types.js').Principal} audience
* @param {import('./types.js').ServiceAbility[]} abilities
* @param {Omit<import('./types.js').UCANOptions, 'audience'> & { audienceMeta?: import('./types.js').AgentMeta }} [options]
*/
async createDelegation(audience, abilities, options = {}) {
const audienceMeta = options.audienceMeta ?? {
name: 'agent',
type: 'device',
};
const { root, blocks } = await this._agent.delegate({
...options,
abilities,
audience,
audienceMeta,
});
return new AgentDelegation(root, blocks, { audience: audienceMeta });
}
/**
* Revoke a delegation by CID.
*
* If the delegation was issued by this agent (and therefore is stored in the
* delegation store) you can just pass the CID. If not, or if the current agent's
* delegation store no longer contains the delegation, you MUST pass a chain of
* proofs that proves your authority to revoke this delegation as `options.proofs`.
*
* @param {import('@ucanto/interface').UCANLink} delegationCID
* @param {object} [options]
* @param {import('@ucanto/interface').Delegation[]} [options.proofs]
*/
async revokeDelegation(delegationCID, options = {}) {
return this._agent.revoke(delegationCID, {
proofs: options.proofs,
});
}
/**
* Removes association of a content CID with the space. Optionally, also removes
* association of CAR shards with space.
*
* ⚠️ If `shards` option is `true` all shards will be deleted even if there is another upload(s) that
* reference same shards, which in turn could corrupt those uploads.
*
* Required delegated capabilities:
* - `space/blob/remove`
* - `store/remove`
* - `upload/get`
* - `upload/remove`
*
* @param {import('multiformats').UnknownLink} contentCID
* @param {object} [options]
* @param {boolean} [options.shards]
*/
async remove(contentCID, options = {}) {
// Shortcut if there is no request to remove shards
if (!options.shards) {
// Remove association of content CID with selected space.
await this.capability.upload.remove(contentCID);
return;
}
// Get shards associated with upload.
const upload = await this.capability.upload.get(contentCID);
// Remove shards
if (upload.shards?.length) {
await Promise.allSettled(upload.shards.map((shard) => this.capability.blob.remove(shard.multihash)));
}
// Remove association of content CID with selected space.
await this.capability.upload.remove(contentCID);
}
}
/**
* Authorizes an audience to serve content from the provided space and record egress events.
* It also publishes the delegation to the content serve service.
* Delegates the following capabilities to the audience:
* - `space/content/serve/*`
*
* @param {Client} client - The w3up client instance.
* @param {import('./types.js').OwnedSpace} space - The space to authorize the audience for.
* @param {import('./types.js').ConnectionView<import('./types.js').ContentServeService>} connection - The connection to the Content Serve Service that will handle, validate, and store the access/delegate UCAN invocation.
* @param {object} [options] - Options for the content serve authorization invocation.
* @param {`did:${string}:${string}`} [options.audience] - The Web DID of the audience (gateway or peer) to authorize.
* @param {number} [options.expiration] - The time at which the delegation expires in seconds from unix epoch.
*/
export const authorizeContentServe = async (client, space, connection, options = {}) => {
const currentSpace = client.currentSpace();
try {
// Set the current space to the space we are authorizing the gateway for, otherwise the delegation will fail
await client.setCurrentSpace(space.did());
/** @type {import('@ucanto/client').Principal<`did:${string}:${string}`>} */
const audience = {
did: () => options.audience ?? connection.id.did(),
};
// Grant the audience the ability to serve content from the space, it includes existing proofs automatically
const delegation = await client.createDelegation(audience, [SpaceCapabilities.contentServe.can], {
expiration: options.expiration ?? Infinity,
});
// Publish the delegation to the content serve service
const accessProofs = client.proofs([
{ can: AccessCapabilities.access.can, with: space.did() },
]);
const verificationResult = await AccessCapabilities.delegate
.invoke({
issuer: client.agent.issuer,
audience,
with: space.did(),
proofs: [...accessProofs, delegation],
nb: {
delegations: {
[delegation.cid.toString()]: delegation.cid,
},
},
})
.execute(connection);
/* c8 ignore next 8 - can't mock this error */
if (verificationResult.out.error) {
throw new Error(`failed to publish delegation for audience ${audience.did()}: ${verificationResult.out.error.message}`, {
cause: verificationResult.out.error,
});
}
return { ok: { ...verificationResult.out.ok, delegation } };
}
finally {
if (currentSpace) {
await client.setCurrentSpace(currentSpace.did());
}
}
};
//# sourceMappingURL=client.js.map