diff --git a/.nx/version-plans/version-plan-1769007491408.md b/.nx/version-plans/version-plan-1769007491408.md new file mode 100644 index 000000000..c15b27b0a --- /dev/null +++ b/.nx/version-plans/version-plan-1769007491408.md @@ -0,0 +1,5 @@ +--- +'@storacha/capabilities': minor +--- + +add 'account/egress/get' capability diff --git a/packages/capabilities/src/account/egress.js b/packages/capabilities/src/account/egress.js new file mode 100644 index 000000000..f81f9d83b --- /dev/null +++ b/packages/capabilities/src/account/egress.js @@ -0,0 +1,62 @@ +import { capability, fail, ok, Schema } from '@ucanto/validator' +import { + AccountDID, + SpaceDID, + equalWith, + and, + equal, + containedWithin, +} from '../utils.js' + +/** + * Capability can only be delegated (but not invoked) allowing audience to + * be derived any `account/egress/` prefixed capability for the account identified + * by DID in the `with` field. + */ +export const accountEgress = capability({ + can: 'account/egress/*', + with: AccountDID, + derives: equalWith, +}) + +/** + * Capability can be invoked by an agent to retrieve egress data for all or a + * specified set of spaces within an account in a given period. + */ +export const get = capability({ + can: 'account/egress/get', + with: AccountDID, + nb: Schema.struct({ + spaces: SpaceDID.array().optional(), + /** Period to retrieve egress between. */ + period: Schema.struct({ + /** Date-only string in ISO 8601 format (inclusive). */ + from: Schema.string(), + /** Date-only string in ISO 8601 format (exclusive). */ + to: Schema.string(), + }).optional(), + }), + derives: (child, parent) => { + return ( + and(equalWith(child, parent)) || + and( + (() => { + if (parent.nb.spaces === undefined) { + return ok({}) + } + if (child.nb.spaces === undefined) { + return fail( + `Constraint violation: violates imposed spaces constraint ${parent.nb.spaces} because it asks for all spaces` + ) + } + return containedWithin(child.nb.spaces, parent.nb.spaces, 'spaces') + })() + ) || + 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/src/index.js b/packages/capabilities/src/index.js index f754fbfcb..99f6bf63e 100644 --- a/packages/capabilities/src/index.js +++ b/packages/capabilities/src/index.js @@ -27,6 +27,7 @@ import * as SpaceBlob from './space/blob.js' import * as W3sBlob from './web3.storage/blob.js' import * as HTTP from './http.js' import * as AccountUsage from './account/usage.js' +import * as AccountEgress from './account/egress.js' import * as PDP from './pdp.js' export { @@ -59,6 +60,7 @@ export { W3sBlob, HTTP, AccountUsage, + AccountEgress, PDP, } @@ -132,6 +134,7 @@ export const abilitiesAsStrings = [ SpaceIndex.index.can, SpaceIndex.add.can, AccountUsage.get.can, + AccountEgress.get.can, PDP.accept.can, PDP.info.can, ] diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index f202b040e..bf4c6ef8f 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -45,6 +45,7 @@ import * as UCANCaps from './ucan.js' import * as PlanCaps from './plan.js' import * as UsageCaps from './usage.js' import * as AccountUsageCaps from './account/usage.js' +import * as AccountEgressCaps from './account/egress.js' import * as PDPCaps from './pdp.js' export type ISO8601Date = string @@ -259,6 +260,53 @@ export interface SpaceEgressUsage { providers: Record } +// AccountEgress +export type AccountEgress = InferInvokedCapability< + typeof AccountEgressCaps.accountEgress +> +export type AccountEgressGet = InferInvokedCapability< + typeof AccountEgressCaps.get +> + +export interface AccountNotFoundError extends Ucanto.Failure { + name: 'AccountNotFound' +} + +export interface SpaceUnauthorizedError extends Ucanto.Failure { + name: 'SpaceUnauthorized' +} + +export interface PeriodNotAcceptableError extends Ucanto.Failure { + name: 'PeriodNotAcceptable' +} + +export type AccountEgressGetFailure = + | AccountNotFoundError + | SpaceUnauthorizedError + | PeriodNotAcceptableError + | Ucanto.Failure + +export interface AccountEgressGetSuccess { + // total egress across all spaces in the period in bytes + total: number + // breakdown by space + spaces: Record +} + +export interface SpaceEgress { + // total egress for the space in the period + total: number + // daily egress stats + dailyStats: DailyStats[] +} + +export interface DailyStats { + // date the stats refer to + date: ISO8601Date + // egress for the day in bytes + egress: number +} + // Provider export type ProviderAdd = InferInvokedCapability // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -1240,6 +1288,8 @@ export type ServiceAbilityArray = [ SpaceIndexAdd['can'], AccountUsage['can'], AccountUsageGet['can'], + AccountEgress['can'], + AccountEgressGet['can'], PDPAccept['can'], PDPInfo['can'] ] diff --git a/packages/capabilities/test/capabilities/account/egress.test.js b/packages/capabilities/test/capabilities/account/egress.test.js new file mode 100644 index 000000000..3f0a42798 --- /dev/null +++ b/packages/capabilities/test/capabilities/account/egress.test.js @@ -0,0 +1,298 @@ +import assert from 'assert' +import { access } from '@ucanto/validator' +import { Verifier } from '@ucanto/principal' +import * as AccountEgress from '../../../src/account/egress.js' +import * as Capability from '../../../src/top.js' +import { + alice, + service, + bobAccount as account, + mallory, + space, + bob, +} from '../../helpers/fixtures.js' +import { + delegateWithAttestation, + validateAuthorization, +} from '../../helpers/utils.js' + +const top = () => + delegateWithAttestation(Capability.top, { + account: account.did(), + service, + audience: alice, + with: account.did(), + }) + +const accountEgress = () => + delegateWithAttestation(AccountEgress.accountEgress, { + account: account.did(), + service, + audience: alice, + with: account.did(), + }) + +describe('account/egress/get capabilities', function () { + it('account/egress/get can be derived from *', async () => { + const get = AccountEgress.get.invoke({ + issuer: alice, + audience: service, + with: account.did(), + nb: {}, + proofs: await top(), + }) + + const result = await access(await get.delegate(), { + capability: AccountEgress.get, + principal: Verifier, + authority: service, + validateAuthorization, + }) + assert.ok(result.ok) + assert.deepEqual(result.ok.audience.did(), service.did()) + assert.equal(result.ok.capability.can, AccountEgress.get.can) + }) + + it('account/egress/get can be derived from account/egress/*', async () => { + const get = AccountEgress.get.invoke({ + issuer: alice, + audience: service, + with: account.did(), + nb: {}, + proofs: await accountEgress(), + }) + const result = await access(await get.delegate(), { + capability: AccountEgress.get, + principal: Verifier, + authority: service, + validateAuthorization, + }) + + assert.ok(result.ok) + assert.deepEqual(result.ok.audience.did(), service.did()) + assert.equal(result.ok.capability.can, AccountEgress.get.can) + }) + + it('account/egress/get should fail when escalating period constraint', async () => { + const period = { from: '2026-01-01', to: '2026-01-31' } + const delegation = await AccountEgress.get.delegate({ + issuer: alice, + audience: mallory, + with: account.did(), + nb: { period }, + proofs: await top(), + }) + + { + const get = AccountEgress.get.invoke({ + issuer: mallory, + audience: service, + with: account.did(), + nb: { period: { from: '2025-12-31', to: period.to } }, + proofs: [delegation], + }) + + const result = await access(await get.delegate(), { + capability: AccountEgress.get, + principal: Verifier, + authority: service, + validateAuthorization, + }) + + assert.ok(result.error) + assert( + result.error.message.includes( + `2025-12-31 violates imposed period.from constraint ${period.from}` + ) + ) + } + + { + const get = AccountEgress.get.invoke({ + issuer: mallory, + audience: service, + with: account.did(), + nb: { period: { from: period.from, to: '2026-02-01' } }, + proofs: [delegation], + }) + + const result = await access(await get.delegate(), { + capability: AccountEgress.get, + principal: Verifier, + authority: service, + validateAuthorization, + }) + + assert.ok(result.error) + assert( + result.error.message.includes( + `2026-02-01 violates imposed period.to constraint ${period.to}` + ) + ) + } + }) + + // Note: Schema.string() doesn't validate ISO 8601 date format at the schema level. + // Date format validation should be done at runtime by the service handler. + + it('account/egress/get succeeds when spaces are equal in delegation and invocation', async () => { + const spaces = [space.did()] + const delegation = await AccountEgress.get.delegate({ + issuer: alice, + audience: mallory, + with: account.did(), + nb: { spaces }, + proofs: await top(), + }) + + const get = AccountEgress.get.invoke({ + issuer: mallory, + audience: service, + with: account.did(), + nb: { spaces }, + proofs: [delegation], + }) + + const result = await access(await get.delegate(), { + capability: AccountEgress.get, + principal: Verifier, + authority: service, + validateAuthorization, + }) + + assert.ok(result.ok) + assert.deepEqual(result.ok.audience.did(), service.did()) + assert.equal(result.ok.capability.can, AccountEgress.get.can) + assert.deepEqual(result.ok.capability.nb, { spaces }) + }) + + it('account/egress/get succeeds when delegation has no spaces constraint but invocation specifies spaces', async () => { + const delegation = await AccountEgress.get.delegate({ + issuer: alice, + audience: mallory, + with: account.did(), + nb: {}, + proofs: await top(), + }) + + const spaces = [space.did()] + const get = AccountEgress.get.invoke({ + issuer: mallory, + audience: service, + with: account.did(), + nb: { spaces }, + proofs: [delegation], + }) + + const result = await access(await get.delegate(), { + capability: AccountEgress.get, + principal: Verifier, + authority: service, + validateAuthorization, + }) + + assert.ok(result.ok) + assert.deepEqual(result.ok.audience.did(), service.did()) + assert.equal(result.ok.capability.can, AccountEgress.get.can) + assert.deepEqual(result.ok.capability.nb, { spaces }) + }) + + it('account/egress/get should fail when delegation has spaces constraint but invocation asks for all spaces', async () => { + const spaces = [space.did()] + const delegation = await AccountEgress.get.delegate({ + issuer: alice, + audience: mallory, + with: account.did(), + nb: { spaces }, + proofs: await top(), + }) + + const get = AccountEgress.get.invoke({ + issuer: mallory, + audience: service, + with: account.did(), + nb: {}, + proofs: [delegation], + }) + + const result = await access(await get.delegate(), { + capability: AccountEgress.get, + principal: Verifier, + authority: service, + validateAuthorization, + }) + + assert.ok(result.error) + assert( + result.error.message.includes( + `Constraint violation: violates imposed spaces constraint ${spaces} because it asks for all spaces` + ) + ) + }) + + it('account/egress/get succeeds when invocation asks for subset of delegation spaces', async () => { + const delegationSpaces = [space.did(), bob.did()] + const delegation = await AccountEgress.get.delegate({ + issuer: alice, + audience: mallory, + with: account.did(), + nb: { spaces: delegationSpaces }, + proofs: await top(), + }) + + const invocationSpaces = [space.did()] + const get = AccountEgress.get.invoke({ + issuer: mallory, + audience: service, + with: account.did(), + nb: { spaces: invocationSpaces }, + proofs: [delegation], + }) + + const result = await access(await get.delegate(), { + capability: AccountEgress.get, + principal: Verifier, + authority: service, + validateAuthorization, + }) + + assert.ok(result.ok) + assert.deepEqual(result.ok.audience.did(), service.did()) + assert.equal(result.ok.capability.can, AccountEgress.get.can) + assert.deepEqual(result.ok.capability.nb, { spaces: invocationSpaces }) + }) + + it('account/egress/get should fail when invocation asks for spaces not in delegation constraint', async () => { + const delegationSpaces = [space.did()] + const delegation = await AccountEgress.get.delegate({ + issuer: alice, + audience: mallory, + with: account.did(), + nb: { spaces: delegationSpaces }, + proofs: await top(), + }) + + const invocationSpaces = [space.did(), bob.did()] + const get = AccountEgress.get.invoke({ + issuer: mallory, + audience: service, + with: account.did(), + nb: { spaces: invocationSpaces }, + proofs: [delegation], + }) + + const result = await access(await get.delegate(), { + capability: AccountEgress.get, + principal: Verifier, + authority: service, + validateAuthorization, + }) + + assert.ok(result.error) + assert( + result.error.message.includes( + `Constraint violation: ${invocationSpaces} violates imposed spaces constraint ${delegationSpaces} because it contains items not in the constraint` + ) + ) + }) +})