Skip to content

Commit

Permalink
feat: implement plan/get capability (#1005)
Browse files Browse the repository at this point in the history
In console, we need a way to tell if a user has a subscription.
Implement the `plan/get` capability from
#959 to enable that.

Includes the core capability definitions plus `upload-api` handlers and
`access-client` helper functions.
  • Loading branch information
travis authored Oct 26, 2023
1 parent 64b6b0f commit f0456d2
Show file tree
Hide file tree
Showing 20 changed files with 457 additions and 7 deletions.
14 changes: 13 additions & 1 deletion packages/access-client/src/agent-use-cases.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { addSpacesFromDelegations, Agent as AccessAgent } from './agent.js'
import * as Ucanto from '@ucanto/interface'
import * as Access from '@web3-storage/capabilities/access'
import { bytesToDelegations } from './encoding.js'
import { Provider } from '@web3-storage/capabilities'
import { Provider, Plan } from '@web3-storage/capabilities'
import * as w3caps from '@web3-storage/capabilities'
import { AgentData, isSessionProof } from './agent-data.js'
import * as ucanto from '@ucanto/core'
Expand Down Expand Up @@ -330,3 +330,15 @@ async function createIssuerSaysAccountCanAdminSpace(
expiration,
})
}

/**
*
* @param {AccessAgent} agent
* @param {import('@web3-storage/did-mailto/types').DidMailto} account
*/
export async function getAccountPlan(agent, account) {
const receipt = await agent.invokeAndExecute(Plan.get, {
with: account,
})
return receipt.out
}
62 changes: 62 additions & 0 deletions packages/access-client/test/agent-use-cases.test.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import assert from 'assert'
import sinon from 'sinon'
import * as Server from '@ucanto/server'
import * as Ucanto from '@ucanto/interface'
import * as Access from '@web3-storage/capabilities/access'
import * as Space from '@web3-storage/capabilities/space'
import * as Plan from '@web3-storage/capabilities/plan'
import { createAuthorization } from '@web3-storage/capabilities/test/helpers/utils'
import { Agent, connection } from '../src/agent.js'
import {
delegationsIncludeSessionProof,
authorizeWaitAndClaim,
waitForAuthorizationByPolling,
getAccountPlan,
} from '../src/agent-use-cases.js'
import { createServer } from './helpers/utils.js'
import * as fixtures from './helpers/fixtures.js'
Expand Down Expand Up @@ -188,3 +192,61 @@ describe('authorizeWaitAndClaim', async function () {
assert(claimHandler.notCalled)
})
})

describe('getAccountPlan', async function () {
const accountWithAPlan = 'did:mailto:example.com:i-have-a-plan'
const accountWithoutAPlan = 'did:mailto:example.com:i-have-no-plan'

/** @type {Record<Ucanto.DID, {product: Ucanto.DID, updatedAt: string}>} */
const plans = {
[accountWithAPlan]: {
product: 'did:web:test.web3.storage',
updatedAt: new Date().toISOString(),
},
}

const server = createServer({
plan: {
get: Server.provide(Plan.get, ({ capability }) => {
const plan = plans[capability.with]
return plan
? { ok: plan }
: {
error: {
name: 'PlanNotFound',
message: '',
},
}
}),
},
})
const agent = await Agent.create(undefined, {
connection: connection({ principal: server.id, channel: server }),
})

await Promise.all(
[
...(await createAuthorization({
account: accountWithAPlan,
agent: agent.issuer,
service: server.id,
})),
...(await createAuthorization({
account: accountWithoutAPlan,
agent: agent.issuer,
service: server.id,
})),
].map((proof) => agent.addProof(proof))
)

it('should succeed for accounts with plans', async function () {
const result = await getAccountPlan(agent, accountWithAPlan)
assert(result.ok)
})

it('should fail for accounts without a plan', async function () {
const result = await getAccountPlan(agent, accountWithoutAPlan)
assert(result.error)
assert.equal(result.error.name, 'PlanNotFound')
})
})
4 changes: 4 additions & 0 deletions packages/capabilities/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
"types": "./dist/src/*.d.ts",
"import": "./src/*.js"
},
"./test/helpers/*": {
"types": "./dist/test/helpers/*.d.ts",
"import": "./test/helpers/*.js"
},
"./filecoin": {
"types": "./dist/src/filecoin/index.d.ts",
"import": "./src/filecoin/index.js"
Expand Down
3 changes: 3 additions & 0 deletions packages/capabilities/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as Aggregator from './filecoin/aggregator.js'
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'

export {
Access,
Expand All @@ -38,6 +39,7 @@ export {
DealTracker,
Admin,
UCAN,
Plan,
}

/** @type {import('./types.js').AbilitiesArray} */
Expand Down Expand Up @@ -77,4 +79,5 @@ export const abilitiesAsStrings = [
Admin.admin.can,
Admin.upload.inspect.can,
Admin.store.inspect.can,
Plan.get.can,
]
16 changes: 16 additions & 0 deletions packages/capabilities/src/plan.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { capability, DID, ok } from '@ucanto/validator'
import { equalWith, and } from './utils.js'

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

/**
* Capability can be invoked by an account to get information about
* the plan it is currently signed up for.
*/
export const get = capability({
can: 'plan/get',
with: AccountDID,
derives: (child, parent) => {
return and(equalWith(child, parent)) || ok({})
},
})
27 changes: 23 additions & 4 deletions packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ import * as DealTrackerCaps from './filecoin/deal-tracker.js'
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'

export type ISO8601Date = string

export type { Unit, PieceLink }

Expand Down Expand Up @@ -394,14 +397,14 @@ export interface StoreListItem {
link: UnknownLink
size: number
origin?: UnknownLink
insertedAt: string
insertedAt: ISO8601Date
}

export interface UploadListItem {
root: UnknownLink
shards?: CARLink[]
insertedAt: string
updatedAt: string
insertedAt: ISO8601Date
updatedAt: ISO8601Date
}

// TODO: (olizilla) make this an UploadListItem too?
Expand Down Expand Up @@ -514,6 +517,21 @@ export type AggregateAccept = InferInvokedCapability<
typeof DealerCaps.aggregateAccept
>
export type DealInfo = InferInvokedCapability<typeof DealTrackerCaps.dealInfo>

// Plan

export type PlanGet = InferInvokedCapability<typeof PlanCaps.get>
export interface PlanGetSuccess {
updatedAt: ISO8601Date
product: DID
}

export interface PlanNotFound extends Ucanto.Failure {
name: 'PlanNotFound'
}

export type PlanGetFailure = PlanNotFound

// Top
export type Top = InferInvokedCapability<typeof top>

Expand Down Expand Up @@ -554,5 +572,6 @@ export type AbilitiesArray = [
DealInfo['can'],
Admin['can'],
AdminUploadInspect['can'],
AdminStoreInspect['can']
AdminStoreInspect['can'],
PlanGet['can']
]
96 changes: 96 additions & 0 deletions packages/capabilities/test/capabilities/plan.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import assert from 'assert'
import { access } from '@ucanto/validator'
import { Verifier } from '@ucanto/principal/ed25519'
import * as Plan from '../../src/plan.js'
import { service, alice, bob } from '../helpers/fixtures.js'
import { createAuthorization, validateAuthorization } from '../helpers/utils.js'

describe('plan/get', function () {
const agent = alice
const account = 'did:mailto:mallory.com:mallory'
it('can invoke as an account', async function () {
const auth = Plan.get.invoke({
issuer: agent,
audience: service,
with: account,
proofs: await createAuthorization({ agent, service, account }),
})
const result = await access(await auth.delegate(), {
capability: Plan.get,
principal: Verifier,
authority: service,
validateAuthorization,
})
if (result.error) {
assert.fail(`error in self issue: ${result.error.message}`)
} else {
assert.deepEqual(result.ok.audience.did(), service.did())
assert.equal(result.ok.capability.can, 'plan/get')
assert.deepEqual(result.ok.capability.with, account)
}
})

it('fails without account delegation', async function () {
const agent = alice
const auth = Plan.get.invoke({
issuer: agent,
audience: service,
with: account,
})

const result = await access(await auth.delegate(), {
capability: Plan.get,
principal: Verifier,
authority: service,
validateAuthorization,
})

assert.equal(result.error?.message.includes('not authorized'), true)
})

it('fails when invoked by a different agent', async function () {
const auth = Plan.get.invoke({
issuer: bob,
audience: service,
with: account,
proofs: await createAuthorization({ agent, service, account }),
})

const result = await access(await auth.delegate(), {
capability: Plan.get,
principal: Verifier,
authority: service,
validateAuthorization,
})
assert.equal(result.error?.message.includes('not authorized'), true)
})

it('can delegate plan/get', async function () {
const invocation = Plan.get.invoke({
issuer: bob,
audience: service,
with: account,
proofs: [
await Plan.get.delegate({
issuer: agent,
audience: bob,
with: account,
proofs: await createAuthorization({ agent, service, account }),
}),
],
})
const result = await access(await invocation.delegate(), {
capability: Plan.get,
principal: Verifier,
authority: service,
validateAuthorization,
})
if (result.error) {
assert.fail(`error in self issue: ${result.error.message}`)
} else {
assert.deepEqual(result.ok.audience.did(), service.did())
assert.equal(result.ok.capability.can, 'plan/get')
assert.deepEqual(result.ok.capability.with, account)
}
})
})
2 changes: 2 additions & 0 deletions packages/upload-api/src/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { createService as createSubscriptionService } from './subscription.js'
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'

export * from './types.js'

Expand Down Expand Up @@ -48,6 +49,7 @@ export const createService = (context) => ({
subscription: createSubscriptionService(context),
upload: createUploadService(context),
ucan: createUcanService(context),
plan: createPlanService(context),
})

/**
Expand Down
9 changes: 9 additions & 0 deletions packages/upload-api/src/plan.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as Types from './types.js'
import * as Get from './plan/get.js'

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

/**
* @param {API.PlanServiceContext} context
*/
export const provide = (context) =>
Provider.provide(Plan.get, (input) => get(input, context))

/**
* @param {API.Input<Plan.get>} input
* @param {API.PlanServiceContext} context
* @returns {Promise<API.Result<API.PlanGetSuccess, API.PlanGetFailure>>}
*/
const get = async ({ capability }, context) => {
return context.plansStorage.get(capability.with)
}
Loading

0 comments on commit f0456d2

Please sign in to comment.