Skip to content

Commit

Permalink
feat: usage/record capability definition
Browse files Browse the repository at this point in the history
  • Loading branch information
fforbeck committed Oct 11, 2024
1 parent 06fc93f commit 845af69
Show file tree
Hide file tree
Showing 10 changed files with 210 additions and 3 deletions.
15 changes: 15 additions & 0 deletions packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ export type UsageReport = InferInvokedCapability<typeof UsageCaps.report>
export type UsageReportSuccess = Record<ProviderDID, UsageData>
export type UsageReportFailure = Ucanto.Failure

export type EgressRecord = InferInvokedCapability<typeof UsageCaps.record>
export type EgressRecordSuccess = Unit
export type EgressRecordFailure = Ucanto.Failure

export interface UsageData {
/** Provider the report concerns, e.g. `did:web:web3.storage` */
provider: ProviderDID
Expand Down Expand Up @@ -161,6 +165,17 @@ export interface UsageData {
}>
}

export interface EgressData {
/** Id of the customer that is being billed. */
customer: AccountDID
/** CID of the resource that was served. */
resourceCID: string
/** Amount of bytes served. */
bytes: number
/** ISO datetime that the bytes were served at. */
servedAt: ISO8601Date
}

// Provider
export type ProviderAdd = InferInvokedCapability<typeof provider.add>
// eslint-disable-next-line @typescript-eslint/no-empty-interface
Expand Down
21 changes: 20 additions & 1 deletion packages/capabilities/src/usage.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { capability, ok, Schema } from '@ucanto/validator'
import { and, equal, equalWith, SpaceDID } from './utils.js'
import { AccountDID, and, equal, equalWith, SpaceDID } from './utils.js'

/**
* Capability can only be delegated (but not invoked) allowing audience to
Expand Down Expand Up @@ -40,3 +40,22 @@ export const report = capability({
)
},
})

/**
* Capability can be invoked by an agent to record usage data for a given resource.
*/
export const record = capability({
can: 'usage/record',
with: SpaceDID,
nb: Schema.struct({
/** MailTo DID of the customer that is being billed. */
customer: AccountDID,
/** CID of the resource that was served. */
resourceCID: Schema.string(),
/** Amount of bytes served. */
bytes: Schema.integer().greaterThan(0),
/** Timestamp of the event in seconds after Unix epoch. */
servedAt: Schema.integer().greaterThan(-1),
}),
derives: equalWith,
})
8 changes: 8 additions & 0 deletions packages/upload-api/src/types/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
ProviderDID,
SpaceDID,
UsageData,
EgressData,
AccountDID,
} from '@web3-storage/capabilities/types'

export type { UsageData }
Expand All @@ -13,4 +15,10 @@ export interface UsageStorage {
space: SpaceDID,
period: { from: Date; to: Date }
) => Promise<Result<UsageData, Failure>>
record: (
customer: AccountDID,
resourceCID: string,
bytes: number,
servedAt: Date
) => Promise<Result<EgressData, Failure>>
}
8 changes: 6 additions & 2 deletions packages/upload-api/src/usage.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { provide } from './usage/report.js'
import { provide as provideReport } from './usage/report.js'
import { provide as provideRecord } from './usage/record.js'

/** @param {import('./types.js').UsageServiceContext} context */
export const createService = (context) => ({ report: provide(context) })
export const createService = (context) => ({
report: provideReport(context),
record: provideRecord(context),
})
24 changes: 24 additions & 0 deletions packages/upload-api/src/usage/record.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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.record, (input) => record(input, context))

/**
* @param {API.Input<Usage.record>} input
* @param {API.UsageServiceContext} context
* @returns {Promise<API.Result<API.EgressRecordSuccess, API.EgressRecordFailure>>}
*/
const record = async ({ capability }, context) => {
const res = await context.usageStorage.record(
capability.nb.customer,
capability.nb.resourceCID,
capability.nb.bytes,
new Date(capability.nb.servedAt * 1000)
)
if (res.error) return res

return res
}
29 changes: 29 additions & 0 deletions packages/upload-api/test/storage/usage-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export class UsageStorage {
constructor(storeTable, allocationsStorage) {
this.storeTable = storeTable
this.allocationsStorage = allocationsStorage
/**
* @type {Record<import('../types.js').AccountDID, import('../types.js').EgressData>}
*/
this._egressRecords = {}
}

get items() {
Expand Down Expand Up @@ -64,4 +68,29 @@ export class UsageStorage {
},
}
}

/**
* Simulate a record of egress data for a customer.
*
* @param {import('../types.js').AccountDID} customer
* @param {string} resourceCID
* @param {number} bytes
* @param {Date} servedAt
*/
async record(customer, resourceCID, bytes, servedAt) {
const egressData = {
customer,
resourceCID,
bytes,
servedAt: servedAt.toISOString(),
}
this._egressRecords[customer] = egressData
return Promise.resolve({
ok: egressData,
})
}

get egressRecords() {
return this._egressRecords
}
}
8 changes: 8 additions & 0 deletions packages/upload-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ import {
UsageReport,
UsageReportSuccess,
UsageReportFailure,
EgressData,
EgressRecord,
EgressRecordSuccess,
EgressRecordFailure,
ServiceAbility,
} from '@web3-storage/capabilities/types'
import { StorefrontService } from '@web3-storage/filecoin-client/storefront'
Expand Down Expand Up @@ -135,6 +139,10 @@ export type {
UsageReport,
UsageReportSuccess,
UsageReportFailure,
EgressData,
EgressRecord,
EgressRecordSuccess,
EgressRecordFailure,
ListResponse,
CARLink,
PieceLink,
Expand Down
55 changes: 55 additions & 0 deletions packages/w3up-client/src/capability/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,32 @@ export class UsageClient extends Base {

return out.ok
}

/**
* Record egress data for the customer and served resource.
*
* Required delegated capabilities:
* - `usage/record`
*
* @param {import('../types.js').SpaceDID} space
* @param {API.EgressData} egressData
* @param {object} [options]
* @param {string} [options.nonce]
*/
async record(space, egressData, options) {
const out = await record(
{ agent: this.agent },
{ ...options, space, egressData }
)
/* c8 ignore next 7 */
if (!out.ok) {
throw new Error(`failed ${UsageCapabilities.record.can} invocation`, {
cause: out.error,
})
}

return out.ok
}
}

/**
Expand Down Expand Up @@ -61,3 +87,32 @@ export const report = async (
})
return receipt.out
}

/**
* Record egress data for the customer and served resource.
*
* @param {{agent: API.Agent}} client
* @param {object} options
* @param {API.SpaceDID} options.space
* @param {API.EgressData} options.egressData -
* @param {string} [options.nonce]
* @param {API.Delegation[]} [options.proofs]
* @returns {Promise<API.Result<API.Unit, API.EgressRecordFailure>>}
*/
export const record = async (
{ agent },
{ space, egressData, nonce, proofs = [] }
) => {
const receipt = await agent.invokeAndExecute(UsageCapabilities.record, {
with: space,
proofs,
nonce,
nb: {
customer: egressData.customer,
resourceCID: egressData.resourceCID,
bytes: egressData.bytes,
servedAt: Math.floor(new Date(egressData.servedAt).getTime() / 1000),
},
})
return receipt.out
}
4 changes: 4 additions & 0 deletions packages/w3up-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ export type {
UploadListItem,
UsageReportSuccess,
UsageReportFailure,
EgressData,
EgressRecord,
EgressRecordSuccess,
EgressRecordFailure,
ListResponse,
AnyLink,
CARLink,
Expand Down
41 changes: 41 additions & 0 deletions packages/w3up-client/test/capability/usage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AgentData } from '@web3-storage/access/agent'
import { Client } from '../../src/client.js'
import * as Test from '../test.js'
import { receiptsEndpoint } from '../helpers/utils.js'
import { randomCAR } from '../helpers/random.js'

export const UsageClient = Test.withContext({
report: {
Expand Down Expand Up @@ -68,6 +69,46 @@ export const UsageClient = Test.withContext({
assert.deepEqual(report, {})
},
},
record: {
'should record egress': async (
assert,
{ connection, provisionsStorage }
) => {
const alice = new Client(await AgentData.create(), {
// @ts-ignore
serviceConf: {
access: connection,
upload: connection,
},
})

const space = await alice.createSpace('test')
const auth = await space.createAuthorization(alice)
await alice.addSpace(auth)

// Then we setup a billing for this account
await provisionsStorage.put({
// @ts-expect-error
provider: connection.id.did(),
account: alice.agent.did(),
consumer: space.did(),
})

const car = await randomCAR(128)
const resourceCID = car.cid
await alice.capability.upload.add(car.roots[0], [resourceCID])

const result = await alice.capability.upload.get(car.roots[0])

const record = await alice.capability.usage.record(space.did(), {
customer: 'did:mailto:[email protected]',
resourceCID: resourceCID.link().toString(),
bytes: result.root.byteLength,
servedAt: new Date().toISOString(),
})
assert.ok(record)
},
},
})

Test.test({ UsageClient })

0 comments on commit 845af69

Please sign in to comment.