Skip to content

Commit

Permalink
feat: add usage/report capability (storacha#1079)
Browse files Browse the repository at this point in the history
Adds a capability allowing agents to request a usage report for a given
period.

The report includes the size of the space at the start of the period and
at the end of the period, and additionally the events that caused the
space to change size. This should allow us to display current storage
usage for a space as well as graph space size change over time.

---------

Co-authored-by: Benjamin Goering <171782+gobengo@users.noreply.github.com>
Co-authored-by: Vasco Santos <santos.vasco10@gmail.com>
  • Loading branch information
3 people authored Nov 7, 2023
1 parent b1f860b commit 6418b4b
Show file tree
Hide file tree
Showing 25 changed files with 563 additions and 21 deletions.
1 change: 1 addition & 0 deletions packages/access-client/src/agent-use-cases.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export async function authorizeAndWait(access, email, opts = {}) {
{ can: 'upload/*' },
{ can: 'ucan/*' },
{ can: 'plan/*' },
{ can: 'usage/*' },
{ can: 'w3up/*' },
]
)
Expand Down
4 changes: 4 additions & 0 deletions packages/capabilities/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -40,6 +41,7 @@ export {
Admin,
UCAN,
Plan,
Usage,
}

/** @type {import('./types.js').AbilitiesArray} */
Expand Down Expand Up @@ -80,4 +82,6 @@ export const abilitiesAsStrings = [
Admin.upload.inspect.can,
Admin.store.inspect.can,
Plan.get.can,
Usage.usage.can,
Usage.report.can,
]
42 changes: 41 additions & 1 deletion packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -104,6 +105,43 @@ export interface DelegationNotFound extends Ucanto.Failure {

export type AccessConfirm = InferInvokedCapability<typeof AccessCaps.confirm>

// Usage

export type Usage = InferInvokedCapability<typeof UsageCaps.usage>
export type UsageReport = InferInvokedCapability<typeof UsageCaps.report>
export type UsageReportSuccess = Record<ProviderDID, UsageData>
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<typeof provider.add>
// eslint-disable-next-line @typescript-eslint/no-empty-interface
Expand Down Expand Up @@ -580,5 +618,7 @@ export type AbilitiesArray = [
Admin['can'],
AdminUploadInspect['can'],
AdminStoreInspect['can'],
PlanGet['can']
PlanGet['can'],
Usage['can'],
UsageReport['can']
]
42 changes: 42 additions & 0 deletions packages/capabilities/src/usage.js
Original file line number Diff line number Diff line change
@@ -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({})
)
},
})
203 changes: 203 additions & 0 deletions packages/capabilities/test/capabilities/usage.test.js
Original file line number Diff line number Diff line change
@@ -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/)
})
})
5 changes: 3 additions & 2 deletions packages/did-mailto/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
3 changes: 2 additions & 1 deletion packages/filecoin-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
5 changes: 3 additions & 2 deletions packages/upload-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Loading

0 comments on commit 6418b4b

Please sign in to comment.