From 0bc589ba3ea41464072f7d3beefafd58681cc1fa Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Wed, 22 Nov 2023 11:39:58 +0000 Subject: [PATCH] feat!: account plan subscriptions and space usage API sugar (#1171) This PR allows you to get a list of subscriptions for your account plan, and also get the current per space usage (I've not exposed the full report yet since we don't have any UI that would use it). e.g. Getting subscriptions list: ```js const account = Object.values(client.accounts())[0] const subs = await account.plan.subscriptions() for (const sub of subs.ok) { console.log(`ID: ${sub.subscription}`) console.log(`Consumers: ${sub.consumers.join(', ')}`) console.log(`Provider: ${sub.provider}`) } ``` Getting usage: ```js const space = client.spaces()[0] const usage = await space.usage.get() console.log(`${space.did()}: ${usage.ok} bytes`) ``` So, you no longer have to invoke capabilities directly like this: https://github.com/web3-storage/w3cli/blob/ca21f6736fb8c2180d3634f40931b91e4f0bb964/index.js#L553-L571 --- packages/w3up-client/src/account.js | 8 ++ .../src/capability/subscription.js | 40 +++++---- packages/w3up-client/src/capability/usage.js | 52 ++++++----- packages/w3up-client/src/client.js | 12 ++- packages/w3up-client/src/space.js | 88 +++++++++++++++---- packages/w3up-client/src/types.ts | 2 + packages/w3up-client/test/account.test.js | 23 +++++ packages/w3up-client/test/space.test.js | 58 ++++++++++-- 8 files changed, 211 insertions(+), 72 deletions(-) diff --git a/packages/w3up-client/src/account.js b/packages/w3up-client/src/account.js index d86962a3b..1cccf775e 100644 --- a/packages/w3up-client/src/account.js +++ b/packages/w3up-client/src/account.js @@ -1,6 +1,7 @@ import * as API from './types.js' import * as Access from './capability/access.js' import * as Plan from './capability/plan.js' +import * as Subscription from './capability/subscription.js' import { Delegation, importAuthorization } from '@web3-storage/access/agent' import { add as provision, AccountDID } from '@web3-storage/access/provider' import { fromEmail, toEmail } from '@web3-storage/did-mailto' @@ -211,4 +212,11 @@ export class AccountPlan { proofs: this.model.proofs, }) } + + async subscriptions() { + return await Subscription.list(this.model, { + account: this.model.id, + proofs: this.model.proofs, + }) + } } diff --git a/packages/w3up-client/src/capability/subscription.js b/packages/w3up-client/src/capability/subscription.js index 5b841eb23..8f6a13a63 100644 --- a/packages/w3up-client/src/capability/subscription.js +++ b/packages/w3up-client/src/capability/subscription.js @@ -1,4 +1,5 @@ import { Subscription as SubscriptionCapabilities } from '@web3-storage/capabilities' +import * as API from '../types.js' import { Base } from '../base.js' /** @@ -11,30 +12,33 @@ export class SubscriptionClient extends Base { * @param {import('@web3-storage/access').AccountDID} account */ async list(account) { - const result = await SubscriptionCapabilities.list - .invoke({ - issuer: this._agent.issuer, - audience: this._serviceConf.access.id, - with: account, - proofs: this._agent.proofs([ - { - can: SubscriptionCapabilities.list.can, - with: account, - }, - ]), - nb: {}, - }) - .execute(this._serviceConf.access) - - if (!result.out.ok) { + const out = await list({ agent: this.agent }, { account }) + if (!out.ok) { throw new Error( `failed ${SubscriptionCapabilities.list.can} invocation`, { - cause: result.out.error, + cause: out.error, } ) } - return result.out.ok + return out.ok } } + +/** + * Gets subscriptions associated with the account. + * + * @param {{agent: API.Agent}} client + * @param {object} options + * @param {API.AccountDID} options.account + * @param {API.Delegation[]} [options.proofs] + */ +export const list = async ({ agent }, { account, proofs = [] }) => { + const receipt = await agent.invokeAndExecute(SubscriptionCapabilities.list, { + with: account, + proofs, + nb: {}, + }) + return receipt.out +} diff --git a/packages/w3up-client/src/capability/usage.js b/packages/w3up-client/src/capability/usage.js index f4cd98cd0..aeccc715c 100644 --- a/packages/w3up-client/src/capability/usage.js +++ b/packages/w3up-client/src/capability/usage.js @@ -1,4 +1,5 @@ import { Usage as UsageCapabilities } from '@web3-storage/capabilities' +import * as API from '../types.js' import { Base } from '../base.js' /** @@ -12,32 +13,37 @@ export class UsageClient extends Base { * @param {{ from: Date, to: Date }} period */ async report(space, period) { - const result = await UsageCapabilities.report - .invoke({ - issuer: this._agent.issuer, - audience: this._serviceConf.upload.id, - with: space, - proofs: this._agent.proofs([ - { - can: UsageCapabilities.report.can, - with: space, - }, - ]), - nb: { - period: { - from: Math.floor(period.from.getTime() / 1000), - to: Math.floor(period.to.getTime() / 1000), - }, - }, - }) - .execute(this._serviceConf.upload) - - if (!result.out.ok) { + const out = await report({ agent: this.agent }, { space, period }) + if (!out.ok) { throw new Error(`failed ${UsageCapabilities.report.can} invocation`, { - cause: result.out.error, + cause: out.error, }) } - return result.out.ok + return out.ok } } + +/** + * Get a usage report for the period. + * + * @param {{agent: API.Agent}} client + * @param {object} options + * @param {API.SpaceDID} options.space + * @param {{ from: Date, to: Date }} options.period + * @param {API.Delegation[]} [options.proofs] + * @returns {Promise>} + */ +export const report = async ({ agent }, { space, period, proofs = [] }) => { + const receipt = await agent.invokeAndExecute(UsageCapabilities.report, { + with: space, + proofs, + nb: { + period: { + from: Math.floor(period.from.getTime() / 1000), + to: Math.ceil(period.to.getTime() / 1000), + }, + }, + }) + return receipt.out +} diff --git a/packages/w3up-client/src/client.js b/packages/w3up-client/src/client.js index e9ab21c75..f4ca363e3 100644 --- a/packages/w3up-client/src/client.js +++ b/packages/w3up-client/src/client.js @@ -187,8 +187,11 @@ export class Client extends Base { * The current space. */ currentSpace() { - const did = this._agent.currentSpace() - return did ? new Space(did, this._agent.spaces.get(did)) : undefined + const agent = this._agent + const id = agent.currentSpace() + if (!id) return + const meta = agent.spaces.get(id) + return new Space({ id, meta, agent }) } /** @@ -204,8 +207,9 @@ export class Client extends Base { * Spaces available to this agent. */ spaces() { - return [...this._agent.spaces].map(([did, meta]) => { - return new Space(did, meta) + return [...this._agent.spaces].map(([id, meta]) => { + // @ts-expect-error id is not did:key + return new Space({ id, meta, agent: this._agent }) }) } diff --git a/packages/w3up-client/src/space.js b/packages/w3up-client/src/space.js index 18163297c..b3bb2aa04 100644 --- a/packages/w3up-client/src/space.js +++ b/packages/w3up-client/src/space.js @@ -1,19 +1,23 @@ export * from '@web3-storage/access/space' +import * as Usage from './capability/usage.js' +import * as API from './types.js' -export class Space { - /** @type {import('./types.js').DID} */ - #did +/** + * @typedef {object} Model + * @property {API.SpaceDID} id + * @property {{name?:string}} [meta] + * @property {API.Agent} agent + */ - /** @type {Record} */ - #meta +export class Space { + #model /** - * @param {import('./types.js').DID} did - * @param {Record} meta + * @param {Model} model */ - constructor(did, meta = {}) { - this.#did = did - this.#meta = meta + constructor(model) { + this.#model = model + this.usage = new StorageUsage(model) } /** @@ -21,27 +25,75 @@ export class Space { */ get name() { /* c8 ignore next */ - return String(this.#meta.name ?? '') + return String(this.#model.meta?.name ?? '') } /** * The DID of the space. */ did() { - return this.#did + return this.#model.id } /** - * Whether the space has been registered with the service. + * User defined space metadata. */ - registered() { - return Boolean(this.#meta.isRegistered) + meta() { + return this.#model.meta } +} + +export class StorageUsage { + #model /** - * User defined space metadata. + * @param {Model} model */ - meta() { - return this.#meta + constructor(model) { + this.#model = model } + + /** + * Get the current usage in bytes. + */ + async get() { + const { agent } = this.#model + const space = this.#model.id + const now = new Date() + const period = { + // we may not have done a snapshot for this month _yet_, so get report + // from last month -> now + from: startOfLastMonth(now), + to: now, + } + const result = await Usage.report({ agent }, { space, period }) + /* c8 ignore next */ + if (result.error) return result + + const provider = /** @type {API.ProviderDID} */ (agent.connection.id.did()) + const report = result.ok[provider] + + return { + /* c8 ignore next */ + ok: report?.size.final == null ? undefined : BigInt(report.size.final), + } + } +} + +/** @param {string|number|Date} now */ +const startOfMonth = (now) => { + const d = new Date(now) + d.setUTCDate(1) + d.setUTCHours(0) + d.setUTCMinutes(0) + d.setUTCSeconds(0) + d.setUTCMilliseconds(0) + return d +} + +/** @param {string|number|Date} now */ +const startOfLastMonth = (now) => { + const d = startOfMonth(now) + d.setUTCMonth(d.getUTCMonth() - 1) + return d } diff --git a/packages/w3up-client/src/types.ts b/packages/w3up-client/src/types.ts index 650bd3585..027432b5d 100644 --- a/packages/w3up-client/src/types.ts +++ b/packages/w3up-client/src/types.ts @@ -97,6 +97,8 @@ export type { UploadAddSuccess, UploadRemoveSuccess, UploadListSuccess, + UsageReportSuccess, + UsageReportFailure, ListResponse, AnyLink, CARLink, diff --git a/packages/w3up-client/test/account.test.js b/packages/w3up-client/test/account.test.js index f2358ec44..c5e5fdf0a 100644 --- a/packages/w3up-client/test/account.test.js +++ b/packages/w3up-client/test/account.test.js @@ -240,6 +240,29 @@ export const testAccount = { assert.ok(plan?.product, 'did:web:free.web3.storage') }, + 'check account subscriptions': async ( + assert, + { client, mail, grantAccess } + ) => { + const space = await client.createSpace('test') + + const email = 'alice@web.mail' + const login = Account.login(client, email) + const message = await mail.take() + assert.deepEqual(message.to, email) + await grantAccess(message) + const account = Result.try(await login) + + Result.try(await account.provision(space.did())) + + const subs = Result.unwrap(await account.plan.subscriptions()) + + assert.equal(subs.results.length, 1) + assert.equal(subs.results[0].provider, client.defaultProvider()) + assert.deepEqual(subs.results[0].consumers, [space.did()]) + assert.equal(typeof subs.results[0].subscription, 'string') + }, + 'space.save': async (assert, { client, mail, grantAccess }) => { const space = await client.createSpace('test') assert.deepEqual(client.spaces(), []) diff --git a/packages/w3up-client/test/space.test.js b/packages/w3up-client/test/space.test.js index e858e3745..de9bf395e 100644 --- a/packages/w3up-client/test/space.test.js +++ b/packages/w3up-client/test/space.test.js @@ -1,16 +1,56 @@ import * as Signer from '@ucanto/principal/ed25519' -import assert from 'assert' +import * as StoreCapabilities from '@web3-storage/capabilities/store' +import * as Test from './test.js' import { Space } from '../src/space.js' +import * as Account from '../src/account.js' +import * as Result from '../src/result.js' +import { randomCAR } from './helpers/random.js' -describe('spaces', () => { - it('should get meta', async () => { +/** + * @type {Test.Suite} + */ +export const testSpace = { + 'should get meta': async (assert, { client }) => { const signer = await Signer.generate() const name = `space-${Date.now()}` - const isRegistered = true - const space = new Space(signer.did(), { name, isRegistered }) + const space = new Space({ + id: signer.did(), + meta: { name }, + agent: client.agent, + }) assert.equal(space.did(), signer.did()) assert.equal(space.name, name) - assert.equal(space.registered(), isRegistered) - assert.equal(space.meta().name, name) - }) -}) + assert.equal(space.meta()?.name, name) + }, + + 'should get usage': async (assert, { client, grantAccess, mail }) => { + const space = await client.createSpace('test') + + const email = 'alice@web.mail' + const login = Account.login(client, email) + const message = await mail.take() + assert.deepEqual(message.to, email) + await grantAccess(message) + const account = Result.try(await login) + + Result.try(await account.provision(space.did())) + await space.save() + + const size = 1138 + const archive = await randomCAR(size) + await client.agent.invokeAndExecute(StoreCapabilities.add, { + nb: { + link: archive.cid, + size, + }, + }) + + const found = client.spaces().find((s) => s.did() === space.did()) + if (!found) return assert.fail('space not found') + + const usage = Result.unwrap(await found.usage.get()) + assert.equal(usage, BigInt(size)) + }, +} + +Test.test({ Space: testSpace })