diff --git a/packages/access-client/src/agent-use-cases.js b/packages/access-client/src/agent-use-cases.js index c32f2f2c5..969f3b973 100644 --- a/packages/access-client/src/agent-use-cases.js +++ b/packages/access-client/src/agent-use-cases.js @@ -186,6 +186,7 @@ export async function authorizeAndWait(access, email, opts = {}) { { can: 'upload/*' }, { can: 'ucan/*' }, { can: 'plan/*' }, + { can: 'usage/*' }, { can: 'w3up/*' }, ] ) diff --git a/packages/capabilities/src/index.js b/packages/capabilities/src/index.js index 6b6e954b9..7c44a620c 100644 --- a/packages/capabilities/src/index.js +++ b/packages/capabilities/src/index.js @@ -18,6 +18,7 @@ import * as Dealer from './filecoin/dealer.js' import * as DealTracker from './filecoin/deal-tracker.js' import * as UCAN from './ucan.js' import * as Plan from './plan.js' +import * as Usage from './usage.js' export { Access, @@ -40,6 +41,7 @@ export { Admin, UCAN, Plan, + Usage, } /** @type {import('./types.js').AbilitiesArray} */ @@ -80,4 +82,6 @@ export const abilitiesAsStrings = [ Admin.upload.inspect.can, Admin.store.inspect.can, Plan.get.can, + Usage.usage.can, + Usage.report.can, ] diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index 592a46c98..c72d98830 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -35,6 +35,7 @@ import * as DealerCaps from './filecoin/dealer.js' import * as AdminCaps from './admin.js' import * as UCANCaps from './ucan.js' import * as PlanCaps from './plan.js' +import * as UsageCaps from './usage.js' export type ISO8601Date = string @@ -104,6 +105,43 @@ export interface DelegationNotFound extends Ucanto.Failure { export type AccessConfirm = InferInvokedCapability +// Usage + +export type Usage = InferInvokedCapability +export type UsageReport = InferInvokedCapability +export type UsageReportSuccess = Record +export type UsageReportFailure = Ucanto.Failure + +export interface UsageData { + /** Provider the report concerns, e.g. `did:web:web3.storage` */ + provider: ProviderDID + /** Space the report concerns. */ + space: SpaceDID + /** Period the report applies to. */ + period: { + /** ISO datetime the report begins from (inclusive). */ + from: ISO8601Date + /** ISO datetime the report ends at (inclusive). */ + to: ISO8601Date + } + /** Observed space size for the period. */ + size: { + /** Size at the beginning of the report period. */ + initial: number + /** Size at the end of the report period. */ + final: number + } + /** Events that caused the size to change during the period. */ + events: Array<{ + /** CID of the invoked task that caused the size to change. */ + cause: Link + /** Number of bytes that were added or removed. */ + delta: number + /** ISO datetime that the receipt was issued for the change. */ + receiptAt: ISO8601Date + }> +} + // Provider export type ProviderAdd = InferInvokedCapability // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -580,5 +618,7 @@ export type AbilitiesArray = [ Admin['can'], AdminUploadInspect['can'], AdminStoreInspect['can'], - PlanGet['can'] + PlanGet['can'], + Usage['can'], + UsageReport['can'] ] diff --git a/packages/capabilities/src/usage.js b/packages/capabilities/src/usage.js new file mode 100644 index 000000000..d80fb212d --- /dev/null +++ b/packages/capabilities/src/usage.js @@ -0,0 +1,42 @@ +import { capability, ok, Schema } from '@ucanto/validator' +import { and, equal, equalWith, SpaceDID } from './utils.js' + +/** + * Capability can only be delegated (but not invoked) allowing audience to + * be derived any `usage/` prefixed capability for the (memory) space identified + * by DID in the `with` field. + */ +export const usage = capability({ + can: 'usage/*', + /** DID of the (memory) space where usage is derived. */ + with: SpaceDID, + derives: equalWith, +}) + +/** + * Capability can be invoked by an agent to retrieve usage data for a space in + * a given period. + */ +export const report = capability({ + can: 'usage/report', + with: SpaceDID, + nb: Schema.struct({ + /** Period to retrieve events between. */ + period: Schema.struct({ + /** Time in seconds after Unix epoch (inclusive). */ + from: Schema.integer().greaterThan(-1), + /** Time in seconds after Unix epoch (exclusive). */ + to: Schema.integer().greaterThan(-1), + }), + }), + derives: (child, parent) => { + return ( + and(equalWith(child, parent)) || + and( + equal(child.nb.period?.from, parent.nb.period?.from, 'period.from') + ) || + and(equal(child.nb.period?.to, parent.nb.period?.to, 'period.to')) || + ok({}) + ) + }, +}) diff --git a/packages/capabilities/test/capabilities/usage.test.js b/packages/capabilities/test/capabilities/usage.test.js new file mode 100644 index 000000000..3d2d330dc --- /dev/null +++ b/packages/capabilities/test/capabilities/usage.test.js @@ -0,0 +1,203 @@ +import assert from 'assert' +import { access } from '@ucanto/validator' +import { Verifier } from '@ucanto/principal' +import * as Usage from '../../src/usage.js' +import * as Capability from '../../src/top.js' +import { + alice, + service as w3, + mallory as account, + bob, +} from '../helpers/fixtures.js' +import { validateAuthorization } from '../helpers/utils.js' + +const top = async () => + Capability.top.delegate({ + issuer: account, + audience: alice, + with: account.did(), + }) + +const usage = async () => + Usage.usage.delegate({ + issuer: account, + audience: alice, + with: account.did(), + proofs: [await top()], + }) + +describe('usage capabilities', function () { + it('usage/report can be derived from *', async () => { + const period = { from: 0, to: 1 } + const report = Usage.report.invoke({ + issuer: alice, + audience: w3, + with: account.did(), + nb: { period }, + proofs: [await top()], + }) + + const result = await access(await report.delegate(), { + capability: Usage.report, + principal: Verifier, + authority: w3, + validateAuthorization, + }) + + if (result.error) { + assert.fail(result.error.message) + } + + assert.deepEqual(result.ok.audience.did(), w3.did()) + assert.equal(result.ok.capability.can, 'usage/report') + assert.deepEqual(result.ok.capability.nb, { period }) + }) + + it('usage/report can be derived from usage/*', async () => { + const period = { from: 2, to: 3 } + const report = Usage.report.invoke({ + issuer: alice, + audience: w3, + with: account.did(), + nb: { period }, + proofs: [await usage()], + }) + + const result = await access(await report.delegate(), { + capability: Usage.report, + principal: Verifier, + authority: w3, + validateAuthorization, + }) + + if (result.error) { + assert.fail(result.error.message) + } + + assert.deepEqual(result.ok.audience.did(), w3.did()) + assert.equal(result.ok.capability.can, 'usage/report') + assert.deepEqual(result.ok.capability.nb, { period }) + }) + + it('usage/report can be derived from usage/* derived from *', async () => { + const period = { from: 3, to: 4 } + const usage = await Usage.report.delegate({ + issuer: alice, + audience: bob, + with: account.did(), + proofs: [await top()], + }) + + const report = Usage.report.invoke({ + issuer: bob, + audience: w3, + with: account.did(), + nb: { period }, + proofs: [usage], + }) + + const result = await access(await report.delegate(), { + capability: Usage.report, + principal: Verifier, + authority: w3, + validateAuthorization, + }) + + if (result.error) { + assert.fail(result.error.message) + } + + assert.deepEqual(result.ok.audience.did(), w3.did()) + assert.equal(result.ok.capability.can, 'usage/report') + assert.deepEqual(result.ok.capability.nb, { period }) + }) + + it('usage/report sholud fail when escalating period constraint', async () => { + const period = { from: 5, to: 6 } + const delegation = await Usage.report.delegate({ + issuer: alice, + audience: bob, + with: account.did(), + nb: { period }, + proofs: [await top()], + }) + + { + const report = Usage.report.invoke({ + issuer: bob, + audience: w3, + with: account.did(), + nb: { period: { from: period.from + 1, to: period.to } }, + proofs: [delegation], + }) + + const result = await access(await report.delegate(), { + capability: Usage.report, + principal: Verifier, + authority: w3, + validateAuthorization, + }) + + assert.ok(result.error) + assert( + result.error.message.includes( + `${period.from + 1} violates imposed period.from constraint ${ + period.from + }` + ) + ) + } + + { + const report = Usage.report.invoke({ + issuer: bob, + audience: w3, + with: account.did(), + nb: { period: { from: period.from, to: period.to + 1 } }, + proofs: [delegation], + }) + + const result = await access(await report.delegate(), { + capability: Usage.report, + principal: Verifier, + authority: w3, + validateAuthorization, + }) + + assert.ok(result.error) + assert( + result.error.message.includes( + `${period.to + 1} violates imposed period.to constraint ${period.to}` + ) + ) + } + }) + + it('usage/report period from must be an int', async () => { + const period = { from: 5.5, to: 6 } + const proofs = [await top()] + assert.throws(() => { + Usage.report.invoke({ + issuer: alice, + audience: w3, + with: account.did(), + nb: { period }, + proofs, + }) + }, /Expected value of type integer instead got 5\.5/) + }) + + it('usage/report period to must be an int', async () => { + const period = { from: 5, to: 6.6 } + const proofs = [await top()] + assert.throws(() => { + Usage.report.invoke({ + issuer: alice, + audience: w3, + with: account.did(), + nb: { period }, + proofs, + }) + }, /Expected value of type integer instead got 6\.6/) + }) +}) diff --git a/packages/did-mailto/package.json b/packages/did-mailto/package.json index c59bc3fcf..a463a211e 100644 --- a/packages/did-mailto/package.json +++ b/packages/did-mailto/package.json @@ -36,10 +36,11 @@ "test-watch": "pnpm build && mocha --bail --timeout 10s --watch --parallel -n no-warnings -n experimental-vm-modules -n experimental-fetch --watch-files src,test" }, "devDependencies": { - "@web3-storage/eslint-config-w3up": "workspace:^", "@types/assert": "^1.5.6", "@types/mocha": "^10.0.1", - "mocha": "^10.2.0" + "@web3-storage/eslint-config-w3up": "workspace:^", + "mocha": "^10.2.0", + "typescript": "5.2.2" }, "eslintConfig": { "extends": [ diff --git a/packages/filecoin-api/package.json b/packages/filecoin-api/package.json index 1a7987d50..442b51a93 100644 --- a/packages/filecoin-api/package.json +++ b/packages/filecoin-api/package.json @@ -165,8 +165,9 @@ "@web3-storage/filecoin-client": "workspace:^", "mocha": "^10.2.0", "multiformats": "^12.1.2", + "one-webcrypto": "git://github.com/web3-storage/one-webcrypto", "p-wait-for": "^5.0.2", - "one-webcrypto": "git://github.com/web3-storage/one-webcrypto" + "typescript": "5.2.2" }, "eslintConfig": { "extends": [ diff --git a/packages/upload-api/package.json b/packages/upload-api/package.json index ec262a45e..b98eb06e2 100644 --- a/packages/upload-api/package.json +++ b/packages/upload-api/package.json @@ -127,11 +127,12 @@ "@types/mocha": "^10.0.1", "@ucanto/core": "^9.0.0", "@web-std/blob": "^3.0.5", - "@web3-storage/sigv4": "^1.0.2", "@web3-storage/eslint-config-w3up": "workspace:^", + "@web3-storage/sigv4": "^1.0.2", "is-subset": "^0.1.1", "mocha": "^10.2.0", - "one-webcrypto": "git://github.com/web3-storage/one-webcrypto" + "one-webcrypto": "git://github.com/web3-storage/one-webcrypto", + "typescript": "5.2.2" }, "eslintConfig": { "extends": [ diff --git a/packages/upload-api/src/lib.js b/packages/upload-api/src/lib.js index 38a22e6b6..62c5e817f 100644 --- a/packages/upload-api/src/lib.js +++ b/packages/upload-api/src/lib.js @@ -17,6 +17,7 @@ import { createService as createAdminService } from './admin.js' import { createService as createRateLimitService } from './rate-limit.js' import { createService as createUcanService } from './ucan.js' import { createService as createPlanService } from './plan.js' +import { createService as createUsageService } from './usage.js' import { createService as createFilecoinService } from '@web3-storage/filecoin-api/storefront/service' export * from './types.js' @@ -31,7 +32,7 @@ export const createServer = ({ id, codec = Legacy.inbound, ...context }) => codec, service: createService({ ...context, - id + id, }), catch: (error) => context.errorReporter.catch(error), }) @@ -55,6 +56,7 @@ export const createService = (context) => ({ ucan: createUcanService(context), plan: createPlanService(context), filecoin: createFilecoinService(context).filecoin, + usage: createUsageService(context), }) /** diff --git a/packages/upload-api/src/types.ts b/packages/upload-api/src/types.ts index 4793771f3..d2cb56422 100644 --- a/packages/upload-api/src/types.ts +++ b/packages/upload-api/src/types.ts @@ -25,6 +25,7 @@ import { ServiceContext as FilecoinServiceContext } from '@web3-storage/filecoin import { DelegationsStorage as Delegations } from './types/delegations.js' import { ProvisionsStorage as Provisions } from './types/provisions.js' import { RateLimitsStorage as RateLimits } from './types/rate-limits.js' +import { UsageStorage } from './types/usage.js' export type ValidationEmailSend = { to: string @@ -127,6 +128,9 @@ import { PlanGetSuccess, PlanGetFailure, AccessAuthorizeFailure, + UsageReportSuccess, + UsageReportFailure, + UsageReport, } from '@web3-storage/capabilities/types' import * as Capabilities from '@web3-storage/capabilities' import { RevocationsStorage } from './types/revocations.js' @@ -249,6 +253,9 @@ export interface Service extends StorefrontService { plan: { get: ServiceMethod } + usage: { + report: ServiceMethod + } } export type StoreServiceContext = SpaceServiceContext & { @@ -324,6 +331,11 @@ export interface PlanServiceContext { plansStorage: PlansStorage } +export interface UsageServiceContext { + provisionsStorage: Provisions + usageStorage: UsageStorage +} + export interface ServiceContext extends AccessServiceContext, ConsoleServiceContext, @@ -337,7 +349,8 @@ export interface ServiceContext RevocationServiceContext, PlanServiceContext, UploadServiceContext, - FilecoinServiceContext {} + FilecoinServiceContext, + UsageServiceContext {} export interface UcantoServerContext extends ServiceContext, RevocationChecker { id: Signer diff --git a/packages/upload-api/src/types/usage.ts b/packages/upload-api/src/types/usage.ts new file mode 100644 index 000000000..508125712 --- /dev/null +++ b/packages/upload-api/src/types/usage.ts @@ -0,0 +1,10 @@ +import { Failure, Result } from '@ucanto/interface' +import { ProviderDID, SpaceDID, UsageData } from '../types.js' + +export interface UsageStorage { + report: ( + provider: ProviderDID, + space: SpaceDID, + period: { from: Date; to: Date } + ) => Promise> +} diff --git a/packages/upload-api/src/usage.js b/packages/upload-api/src/usage.js new file mode 100644 index 000000000..04076baca --- /dev/null +++ b/packages/upload-api/src/usage.js @@ -0,0 +1,4 @@ +import { provide } from './usage/report.js' + +/** @param {import('./types.js').UsageServiceContext} context */ +export const createService = (context) => ({ report: provide(context) }) diff --git a/packages/upload-api/src/usage/report.js b/packages/upload-api/src/usage/report.js new file mode 100644 index 000000000..05bc35629 --- /dev/null +++ b/packages/upload-api/src/usage/report.js @@ -0,0 +1,33 @@ +import * as API from '../types.js' +import * as Provider from '@ucanto/server' +import { Usage } from '@web3-storage/capabilities' + +/** @param {API.UsageServiceContext} context */ +export const provide = (context) => + Provider.provide(Usage.report, (input) => report(input, context)) + +/** + * @param {API.Input} input + * @param {API.UsageServiceContext} context + * @returns {Promise>} + */ +const report = async ({ capability }, context) => { + const space = capability.with + const period = { + from: new Date(capability.nb.period.from * 1000), + to: new Date(capability.nb.period.to * 1000), + } + + const res = await context.provisionsStorage.getStorageProviders(space) + if (res.error) return res + + /** @type {Array<[API.ProviderDID, API.UsageData]>} */ + const reports = [] + for (const provider of res.ok) { + const res = await context.usageStorage.report(provider, space, period) + if (res.error) return res + reports.push([res.ok.provider, res.ok]) + } + + return { ok: Object.fromEntries(reports) } +} diff --git a/packages/upload-api/test/handlers/plan.js b/packages/upload-api/test/handlers/plan.js index a5c3a5df7..da55b1d8d 100644 --- a/packages/upload-api/test/handlers/plan.js +++ b/packages/upload-api/test/handlers/plan.js @@ -17,21 +17,23 @@ export const test = { id: context.id, channel: createServer(context), }) - const result = await Plan.get.invoke({ - issuer: alice, - audience: context.service, - with: account, - proofs: await createAuthorization({ - agent: alice, - account: Absentee.from({ id: account }), - service: context.service + const result = await Plan.get + .invoke({ + issuer: alice, + audience: context.service, + with: account, + proofs: await createAuthorization({ + agent: alice, + account: Absentee.from({ id: account }), + service: context.service, + }), }) - }).execute(connection) + .execute(connection) assert.ok(result.out.ok) assert.equal(result.out.ok?.product, product) assert.ok(result.out.ok?.updatedAt) - const date = /** @type {string} */(result.out.ok?.updatedAt) + const date = /** @type {string} */ (result.out.ok?.updatedAt) assert.equal(new Date(Date.parse(date)).toISOString(), date) }, } diff --git a/packages/upload-api/test/handlers/usage.js b/packages/upload-api/test/handlers/usage.js new file mode 100644 index 000000000..56c9b9caf --- /dev/null +++ b/packages/upload-api/test/handlers/usage.js @@ -0,0 +1,57 @@ +import * as CAR from '@ucanto/transport/car' +import { Store, Usage } from '@web3-storage/capabilities' +import * as API from '../../src/types.js' +import { createServer, connect } from '../../src/lib.js' +import { alice, registerSpace } from '../util.js' + +/** @type {API.Tests} */ +export const test = { + 'usage/report retrieves usage data': async (assert, context) => { + const { proof, spaceDid } = await registerSpace(alice, context) + const connection = connect({ + id: context.id, + channel: createServer(context), + }) + + const data = new Uint8Array([11, 22, 34, 44, 55]) + const link = await CAR.codec.link(data) + const size = data.byteLength + + const storeAddRes = await Store.add + .invoke({ + issuer: alice, + audience: context.id, + with: spaceDid, + nb: { link, size }, + proofs: [proof], + }) + .execute(connection) + + assert.ok(storeAddRes.out.ok) + + const usageReportRes = await Usage.report + .invoke({ + issuer: alice, + audience: context.id, + with: spaceDid, + nb: { period: { from: 0, to: Math.ceil(Date.now() / 1000) + 1 } }, + proofs: [proof], + }) + .execute(connection) + + const provider = + /** @type {import('../types.js').ProviderDID} */ + (context.id.did()) + const report = usageReportRes.out.ok?.[provider] + console.log(report) + assert.equal(report?.space, spaceDid) + assert.equal(report?.size.initial, 0) + assert.equal(report?.size.final, size) + assert.equal(report?.events.length, 1) + assert.equal(report?.events[0].delta, size) + assert.equal( + report?.events[0].cause.toString(), + storeAddRes.ran.link().toString() + ) + }, +} diff --git a/packages/upload-api/test/handlers/usage.spec.js b/packages/upload-api/test/handlers/usage.spec.js new file mode 100644 index 000000000..9c8bdbeb6 --- /dev/null +++ b/packages/upload-api/test/handlers/usage.spec.js @@ -0,0 +1,3 @@ +import * as Usage from './usage.js' +import { test } from '../test.js' +test({ 'usage/*': Usage.test }) diff --git a/packages/upload-api/test/helpers/context.js b/packages/upload-api/test/helpers/context.js index cd09456ad..3df1bf22c 100644 --- a/packages/upload-api/test/helpers/context.js +++ b/packages/upload-api/test/helpers/context.js @@ -18,6 +18,7 @@ import * as Types from '../../src/types.js' import * as TestTypes from '../types.js' import { confirmConfirmationUrl } from './utils.js' import { PlansStorage } from '../storage/plans-storage.js' +import { UsageStorage } from '../storage/usage-storage.js' /** * @param {object} options @@ -33,6 +34,7 @@ export const createContext = async (options = {}) => { const dudewhereBucket = new DudewhereBucket() const revocationsStorage = new RevocationsStorage() const plansStorage = new PlansStorage() + const usageStorage = new UsageStorage(storeTable) const signer = await Signer.generate() const aggregatorSigner = await Signer.generate() const id = signer.withDID('did:web:test.web3.storage') @@ -58,6 +60,7 @@ export const createContext = async (options = {}) => { delegationsStorage: new DelegationsStorage(), rateLimitsStorage: new RateLimitsStorage(), plansStorage, + usageStorage, revocationsStorage, errorReporter: { catch(error) { diff --git a/packages/upload-api/test/lib.js b/packages/upload-api/test/lib.js index 56e1d8105..87c5a39c2 100644 --- a/packages/upload-api/test/lib.js +++ b/packages/upload-api/test/lib.js @@ -8,6 +8,8 @@ import * as RateLimitList from './handlers/rate-limit/list.js' import * as RateLimitRemove from './handlers/rate-limit/remove.js' import * as Store from './handlers/store.js' import * as Upload from './handlers/upload.js' +import * as Plan from './handlers/plan.js' +import * as Usage from './handlers/usage.js' import { test as delegationsStorageTests } from './storage/delegations-storage-tests.js' import { test as provisionsStorageTests } from './storage/provisions-storage-tests.js' import { test as rateLimitsStorageTests } from './storage/rate-limits-storage-tests.js' @@ -42,6 +44,8 @@ export const handlerTests = { ...RateLimitRemove, ...Store.test, ...Upload.test, + ...Plan.test, + ...Usage.test, } export { diff --git a/packages/upload-api/test/storage/plans-storage.js b/packages/upload-api/test/storage/plans-storage.js index d8db50043..041b3248c 100644 --- a/packages/upload-api/test/storage/plans-storage.js +++ b/packages/upload-api/test/storage/plans-storage.js @@ -24,8 +24,8 @@ export class PlansStorage { return { error: { name: /** @type {const} */ ('PlanNotFound'), - message: `could not find a plan for ${account}` - } + message: `could not find a plan for ${account}`, + }, } } } diff --git a/packages/upload-api/test/storage/usage-storage.js b/packages/upload-api/test/storage/usage-storage.js new file mode 100644 index 000000000..62c99402a --- /dev/null +++ b/packages/upload-api/test/storage/usage-storage.js @@ -0,0 +1,50 @@ +/** @typedef {import('../../src/types/usage.js').UsageStorage} UsageStore */ + +/** @implements {UsageStore} */ +export class UsageStorage { + /** @param {import('./store-table.js').StoreTable} storeTable */ + constructor(storeTable) { + this.storeTable = storeTable + } + + /** + * @param {import('../types.js').ProviderDID} provider + * @param {import('../types.js').SpaceDID} space + * @param {{ from: Date, to: Date }} period + */ + async report(provider, space, period) { + const before = this.storeTable.items.filter((item) => { + const insertTime = new Date(item.insertedAt).getTime() + return item.space === space && insertTime < period.from.getTime() + }) + const during = this.storeTable.items.filter((item) => { + const insertTime = new Date(item.insertedAt).getTime() + return ( + item.space === space && + insertTime >= period.from.getTime() && + insertTime < period.to.getTime() + ) + }) + const initial = before.reduce((total, item) => (total += item.size), 0) + const final = during.reduce((total, item) => (total += item.size), 0) + + return { + ok: { + provider, + space, + period: { + from: period.from.toISOString(), + to: period.to.toISOString(), + }, + size: { initial, final }, + events: during.map((item) => { + return { + cause: item.invocation.link(), + delta: item.size, + receiptAt: item.insertedAt, + } + }), + }, + } + } +} diff --git a/packages/upload-api/tsconfig.json b/packages/upload-api/tsconfig.json index ec01de0e1..df8158bf7 100644 --- a/packages/upload-api/tsconfig.json +++ b/packages/upload-api/tsconfig.json @@ -6,5 +6,9 @@ }, "include": ["src", "test"], "exclude": ["**/node_modules/**", "dist"], - "references": [{ "path": "../capabilities" }, { "path": "../access-client"}, { "path": "../filecoin-api" }] + "references": [ + { "path": "../capabilities" }, + { "path": "../access-client" }, + { "path": "../filecoin-api" } + ] } diff --git a/packages/upload-client/src/types.ts b/packages/upload-client/src/types.ts index 16d963fae..187712eee 100644 --- a/packages/upload-client/src/types.ts +++ b/packages/upload-client/src/types.ts @@ -40,6 +40,9 @@ import { UploadGet, UploadGetSuccess, UploadGetFailure, + UsageReport, + UsageReportSuccess, + UsageReportFailure, } from '@web3-storage/capabilities/types' export type { @@ -61,6 +64,9 @@ export type { UploadListItem, UploadRemove, UploadRemoveSuccess, + UsageReport, + UsageReportSuccess, + UsageReportFailure, ListResponse, CARLink, PieceLink, @@ -85,6 +91,9 @@ export interface Service { remove: ServiceMethod list: ServiceMethod } + usage: { + report: ServiceMethod + } } export interface InvocationConfig { diff --git a/packages/upload-client/test/helpers/mocks.js b/packages/upload-client/test/helpers/mocks.js index 555287f04..d9000d064 100644 --- a/packages/upload-client/test/helpers/mocks.js +++ b/packages/upload-client/test/helpers/mocks.js @@ -8,6 +8,7 @@ const notImplemented = () => { * @param {Partial<{ * store: Partial * upload: Partial + * usage: Partial * }>} impl */ export function mockService(impl) { @@ -24,6 +25,9 @@ export function mockService(impl) { list: withCallCount(impl.upload?.list ?? notImplemented), remove: withCallCount(impl.upload?.remove ?? notImplemented), }, + usage: { + report: withCallCount(impl.usage?.report ?? notImplemented), + }, } } diff --git a/packages/w3up-client/src/capability/usage.js b/packages/w3up-client/src/capability/usage.js new file mode 100644 index 000000000..f84c0dc0a --- /dev/null +++ b/packages/w3up-client/src/capability/usage.js @@ -0,0 +1,42 @@ +import { Usage as UsageCapabilities } from '@web3-storage/capabilities' +import { Base } from '../base.js' + +/** + * Client for interacting with the `usage/*` capabilities. + */ +export class UsageClient extends Base { + /** + * Get a usage report for the given time period. + * + * @param {{ from: Date, to: Date }} period + * @param {object} [options] + * @param {import('../types.js').SpaceDID} [options.space] Obtain usage for a different space. + */ + async report(period, options) { + const conf = await this._invocationConfig([UsageCapabilities.report.can]) + + const result = await UsageCapabilities.report + .invoke({ + issuer: conf.issuer, + /* c8 ignore next */ + audience: conf.audience, + with: options?.space ?? conf.with, + proofs: conf.proofs, + nb: { + period: { + from: Math.floor(period.from.getTime() / 1000), + to: Math.floor(period.to.getTime() / 1000), + }, + }, + }) + .execute(this._serviceConf.upload) + + if (!result.out.ok) { + throw new Error(`failed ${UsageCapabilities.report.can} invocation`, { + cause: result.out.error, + }) + } + + return result.out.ok + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 070946331..577ff38e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ importers: mocha: specifier: ^10.2.0 version: 10.2.0 + typescript: + specifier: 5.2.2 + version: 5.2.2 packages/eslint-config-w3up: dependencies: @@ -282,6 +285,9 @@ importers: p-wait-for: specifier: ^5.0.2 version: 5.0.2 + typescript: + specifier: 5.2.2 + version: 5.2.2 packages/filecoin-client: dependencies: @@ -422,6 +428,9 @@ importers: one-webcrypto: specifier: git://github.com/web3-storage/one-webcrypto version: github.com/web3-storage/one-webcrypto/5148cd14d5489a8ac4cd38223870e02db15a2382 + typescript: + specifier: 5.2.2 + version: 5.2.2 packages/upload-client: dependencies: