Skip to content

Commit

Permalink
feat: add subscription/list capability (storacha#1088)
Browse files Browse the repository at this point in the history
Invoking `subscription/list` will retrieve a list of subscriptions for a
given account. The list of subscriptions allows us to discover spaces
(consumers) that are owned (paid for) by the account. With the list of
owned spaces we can make calls to `usage/report` to retrieve and display
usage information.
  • Loading branch information
Alan Shaw authored Nov 9, 2023
1 parent a411255 commit 471d7e5
Show file tree
Hide file tree
Showing 29 changed files with 492 additions and 27 deletions.
1 change: 1 addition & 0 deletions packages/access-client/src/access.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ export const spaceAccess = {
'upload/*': {},
'access/*': {},
'filecoin/*': {},
'usage/*': {},
}

/**
Expand Down
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 @@ -183,6 +183,7 @@ export async function authorizeAndWait(access, email, opts = {}) {
{ can: 'space/*' },
{ can: 'store/*' },
{ can: 'provider/add' },
{ can: 'subscription/list' },
{ can: 'upload/*' },
{ can: 'ucan/*' },
{ can: 'plan/*' },
Expand Down
10 changes: 10 additions & 0 deletions packages/access-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ import type {
PlanGet,
PlanGetSuccess,
PlanGetFailure,
SubscriptionList,
SubscriptionListSuccess,
SubscriptionListFailure,
} from '@web3-storage/capabilities/types'
import type { SetRequired } from 'type-fest'
import { Driver } from './drivers/types.js'
Expand Down Expand Up @@ -116,6 +119,13 @@ export interface Service {
space: {
info: ServiceMethod<SpaceInfo, SpaceInfoResult, Failure | SpaceUnknown>
}
subscription: {
list: ServiceMethod<
SubscriptionList,
SubscriptionListSuccess,
SubscriptionListFailure
>
}
ucan: {
revoke: ServiceMethod<UCANRevoke, UCANRevokeSuccess, UCANRevokeFailure>
}
Expand Down
4 changes: 1 addition & 3 deletions packages/capabilities/src/customer.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { capability, DID, struct, ok } from '@ucanto/validator'
import { equalWith, and, equal } from './utils.js'
import { AccountDID, equalWith, and, equal } from './utils.js'

// e.g. did:web:web3.storage or did:web:staging.web3.storage
export const ProviderDID = DID.match({ method: 'web' })

export const AccountDID = DID.match({ method: 'mailto' })

/**
* Capability can be invoked by a provider to get information about the
* customer.
Expand Down
1 change: 1 addition & 0 deletions packages/capabilities/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const abilitiesAsStrings = [
Consumer.has.can,
Consumer.get.can,
Subscription.get.can,
Subscription.list.can,
RateLimit.add.can,
RateLimit.remove.can,
RateLimit.list.can,
Expand Down
6 changes: 2 additions & 4 deletions packages/capabilities/src/plan.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { capability, DID, ok } from '@ucanto/validator'
import { equalWith, and } from './utils.js'

export const AccountDID = DID.match({ method: 'mailto' })
import { capability, ok } from '@ucanto/validator'
import { AccountDID, equalWith, and } from './utils.js'

/**
* Capability can be invoked by an account to get information about
Expand Down
4 changes: 2 additions & 2 deletions packages/capabilities/src/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@
* @module
*/
import { capability, DID, struct, ok } from '@ucanto/validator'
import { equalWith, and, equal, SpaceDID } from './utils.js'
import { AccountDID, equalWith, and, equal, SpaceDID } from './utils.js'

// e.g. did:web:web3.storage or did:web:staging.web3.storage
export const Provider = DID.match({ method: 'web' })

export const AccountDID = DID.match({ method: 'mailto' })
export { AccountDID }

/**
* Capability can be invoked by an agent to add a provider to a space.
Expand Down
12 changes: 11 additions & 1 deletion packages/capabilities/src/subscription.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { capability, DID, struct, ok, Schema } from '@ucanto/validator'
import { equalWith, and, equal } from './utils.js'
import { AccountDID, equalWith, and, equal } from './utils.js'

// e.g. did:web:web3.storage or did:web:staging.web3.storage
export const ProviderDID = DID.match({ method: 'web' })
Expand All @@ -21,3 +21,13 @@ export const get = capability({
)
},
})

/**
* Capability can be invoked to retrieve the list of subscriptions for an
* account.
*/
export const list = capability({
can: 'subscription/list',
with: AccountDID,
derives: equalWith,
})
14 changes: 14 additions & 0 deletions packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,19 @@ export type SubscriptionGetFailure =
| UnknownProvider
| Ucanto.Failure

export type SubscriptionList = InferInvokedCapability<
typeof SubscriptionCaps.list
>
export interface SubscriptionListSuccess {
results: Array<SubscriptionListItem>
}
export interface SubscriptionListItem {
subscription: string
provider: ProviderDID
consumers: SpaceDID[]
}
export type SubscriptionListFailure = Ucanto.Failure

// Rate Limit
export type RateLimitAdd = InferInvokedCapability<typeof RateLimitCaps.add>
export interface RateLimitAddSuccess {
Expand Down Expand Up @@ -622,6 +635,7 @@ export type AbilitiesArray = [
ConsumerHas['can'],
ConsumerGet['can'],
SubscriptionGet['can'],
SubscriptionList['can'],
RateLimitAdd['can'],
RateLimitRemove['can'],
RateLimitList['can'],
Expand Down
2 changes: 2 additions & 0 deletions packages/capabilities/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const ProviderDID = DID.match({ method: 'web' })

export const SpaceDID = DID.match({ method: 'key' })

export const AccountDID = DID.match({ method: 'mailto' })

/**
* Check URI can be delegated
*
Expand Down
2 changes: 2 additions & 0 deletions packages/upload-api/src/subscription.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import * as Types from './types.js'
import * as Get from './subscription/get.js'
import * as List from './subscription/list.js'

/**
* @param {Types.SubscriptionServiceContext} context
*/
export const createService = (context) => ({
get: Get.provide(context),
list: List.provide(context),
})
17 changes: 17 additions & 0 deletions packages/upload-api/src/subscription/list.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as API from '../types.js'
import * as Server from '@ucanto/server'
import { Subscription } from '@web3-storage/capabilities'

/**
* @param {API.SubscriptionServiceContext} context
*/
export const provide = (context) =>
Server.provide(Subscription.list, (input) => list(input, context))

/**
* @param {API.Input<Subscription.list>} input
* @param {API.SubscriptionServiceContext} context
* @returns {Promise<API.Result<API.SubscriptionListSuccess, API.SubscriptionListFailure>>}
*/
const list = async ({ capability }, context) =>
context.subscriptionsStorage.list(capability.with)
11 changes: 11 additions & 0 deletions packages/upload-api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ import {
SubscriptionGet,
SubscriptionGetSuccess,
SubscriptionGetFailure,
SubscriptionList,
SubscriptionListSuccess,
SubscriptionListFailure,
RateLimitAdd,
RateLimitAddSuccess,
RateLimitAddFailure,
Expand Down Expand Up @@ -152,6 +155,8 @@ export type {
export type { RateLimitsStorage, RateLimit } from './types/rate-limits.js'
import { PlansStorage } from './types/plans.js'
export type { PlansStorage } from './types/plans.js'
import { SubscriptionsStorage } from './types/subscriptions.js'
export type { SubscriptionsStorage }

export interface Service extends StorefrontService {
store: {
Expand Down Expand Up @@ -209,6 +214,11 @@ export interface Service extends StorefrontService {
SubscriptionGetSuccess,
SubscriptionGetFailure
>
list: ServiceMethod<
SubscriptionList,
SubscriptionListSuccess,
SubscriptionListFailure
>
}
'rate-limit': {
add: ServiceMethod<RateLimitAdd, RateLimitAddSuccess, RateLimitAddFailure>
Expand Down Expand Up @@ -319,6 +329,7 @@ export interface ProviderServiceContext {
export interface SubscriptionServiceContext {
signer: EdSigner.Signer
provisionsStorage: Provisions
subscriptionsStorage: SubscriptionsStorage
}

export interface RateLimitServiceContext {
Expand Down
12 changes: 12 additions & 0 deletions packages/upload-api/src/types/subscriptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Result } from '@ucanto/interface'
import {
AccountDID,
SubscriptionListSuccess,
SubscriptionListFailure,
} from '@web3-storage/capabilities/types'

export interface SubscriptionsStorage {
list: (
customer: AccountDID
) => Promise<Result<SubscriptionListSuccess, SubscriptionListFailure>>
}
49 changes: 49 additions & 0 deletions packages/upload-api/test/handlers/subscription.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Subscription } from '@web3-storage/capabilities'
import * as API from '../../src/types.js'
import { createServer, connect } from '../../src/lib.js'
import { alice, registerSpace } from '../util.js'
import { createAuthorization } from '../helpers/utils.js'

/** @type {API.Tests} */
export const test = {
'subscription/list retrieves subscriptions for account': async (
assert,
context
) => {
const spaces = await Promise.all([
registerSpace(alice, context, 'alic_e'),
registerSpace(alice, context, 'alic_e'),
])
const connection = connect({
id: context.id,
channel: createServer(context),
})

const subListRes = await Subscription.list
.invoke({
issuer: alice,
audience: context.id,
with: spaces[0].account.did(),
nb: {},
proofs: await createAuthorization({
agent: alice,
account: spaces[0].account,
service: context.service,
}),
})
.execute(connection)

assert.ok(subListRes.out.ok)

const results = subListRes.out.ok?.results
const totalConsumers = results?.reduce(
(total, s) => total + s.consumers.length,
0
)
assert.equal(totalConsumers, spaces.length)

for (const space of spaces) {
assert.ok(results?.some((s) => s.consumers[0] === space.spaceDid))
}
},
}
3 changes: 3 additions & 0 deletions packages/upload-api/test/handlers/subscription.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import * as Subscription from './subscription.js'
import { test } from '../test.js'
test({ 'subscription/*': Subscription.test })
1 change: 0 additions & 1 deletion packages/upload-api/test/handlers/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ export const test = {
/** @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)
Expand Down
6 changes: 5 additions & 1 deletion packages/upload-api/test/helpers/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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'
import { SubscriptionsStorage } from '../storage/subscriptions-storage.js'

/**
* @param {object} options
Expand All @@ -41,6 +42,8 @@ export const createContext = async (
const revocationsStorage = new RevocationsStorage()
const plansStorage = new PlansStorage()
const usageStorage = new UsageStorage(storeTable)
const provisionsStorage = new ProvisionsStorage(options.providers)
const subscriptionsStorage = new SubscriptionsStorage(provisionsStorage)
const signer = await Signer.generate()
const aggregatorSigner = await Signer.generate()
const dealTrackerSigner = await Signer.generate()
Expand Down Expand Up @@ -69,7 +72,8 @@ export const createContext = async (
signer: id,
email,
url: new URL('http://localhost:8787'),
provisionsStorage: new ProvisionsStorage(options.providers),
provisionsStorage,
subscriptionsStorage,
delegationsStorage: new DelegationsStorage(),
rateLimitsStorage: new RateLimitsStorage(),
plansStorage,
Expand Down
2 changes: 2 additions & 0 deletions packages/upload-api/test/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as RateLimitAdd from './handlers/rate-limit/add.js'
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 Subscription from './handlers/subscription.js'
import * as Upload from './handlers/upload.js'
import * as Plan from './handlers/plan.js'
import * as Usage from './handlers/usage.js'
Expand Down Expand Up @@ -43,6 +44,7 @@ export const handlerTests = {
...RateLimitList,
...RateLimitRemove,
...Store.test,
...Subscription.test,
...Upload.test,
...Plan.test,
...Usage.test,
Expand Down
29 changes: 29 additions & 0 deletions packages/upload-api/test/storage/subscriptions-storage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @typedef {import('../../src/types/subscriptions.js').SubscriptionsStorage} SubscriptionsStore
*/

/**
* @implements {SubscriptionsStore}
*/
export class SubscriptionsStorage {
/** @param {import('./provisions-storage.js').ProvisionsStorage} provisions */
constructor(provisions) {
this.provisionsStore = provisions
}

/** @param {import('../types.js').AccountDID} customer */
async list(customer) {
/** @type {import('../types.js').SubscriptionListItem[]} */
const results = []
const entries = Object.entries(this.provisionsStore.provisions)
for (const [subscription, provision] of entries) {
if (provision.customer !== customer) continue
results.push({
subscription,
provider: provision.provider,
consumers: [provision.consumer],
})
}
return { ok: { results } }
}
}
4 changes: 2 additions & 2 deletions packages/upload-api/test/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ export async function createSpace(audience) {
}

/**
*
* @param {API.Principal & API.Signer} audience
* @param {import('./types.js').UcantoServerTestContext} context
* @param {string} [username]
*/
export const registerSpace = async (audience, context, username = 'alice') => {
const { proof, space, spaceDid } = await createSpace(audience)
Expand All @@ -77,7 +77,7 @@ export const registerSpace = async (audience, context, username = 'alice') => {
})
}

return { proof, space, spaceDid }
return { proof, space, spaceDid, account }
}

/** @param {number} size */
Expand Down
8 changes: 8 additions & 0 deletions packages/w3up-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,18 @@
"types": "./dist/src/capability/store.d.ts",
"import": "./src/capability/store.js"
},
"./capability/subscription": {
"types": "./dist/src/capability/subscription.d.ts",
"import": "./src/capability/subscription.js"
},
"./capability/upload": {
"types": "./dist/src/capability/upload.d.ts",
"import": "./src/capability/upload.js"
},
"./capability/usage": {
"types": "./dist/src/capability/usage.d.ts",
"import": "./src/capability/usage.js"
},
"./types": "./src/types.js"
},
"publishConfig": {
Expand Down
Loading

0 comments on commit 471d7e5

Please sign in to comment.