Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement plan/get capability #1005

Merged
merged 15 commits into from
Oct 26, 2023
Merged
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/src/types.js').DidMailto} account
*/
export async function getAccountPlan(agent, account){
const receipt = await agent.invokeAndExecute(Plan.get, {
with: account
})
return receipt.out
}
51 changes: 51 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,50 @@ 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]
if (plan) {
return { ok: plan }
} else {
return {
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 () {
assert((await getAccountPlan(agent, accountWithAPlan)).ok)
})

it("should fail for accounts without a plan", async function () {
travis marked this conversation as resolved.
Show resolved Hide resolved
assert((await getAccountPlan(agent, accountWithoutAPlan)).error)
})
})
4 changes: 4 additions & 0 deletions packages/capabilities/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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({})
},
})
19 changes: 18 additions & 1 deletion packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ 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 { Unit, PieceLink }

Expand Down Expand Up @@ -514,6 +515,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: string
travis marked this conversation as resolved.
Show resolved Hide resolved
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 +570,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/add', async function () {
travis marked this conversation as resolved.
Show resolved Hide resolved
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)
}
17 changes: 15 additions & 2 deletions packages/upload-api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,16 +113,18 @@ import {
ProviderDID,
StoreGetFailure,
UploadGetFailure,
UCANRevoke,
ListResponse,
CARLink,
StoreGetSuccess,
UploadGetSuccess,
UCANRevoke,
UCANRevokeSuccess,
UCANRevokeFailure,
PlanGet,
PlanGetSuccess,
PlanGetFailure,
} from '@web3-storage/capabilities/types'
import * as Capabilities from '@web3-storage/capabilities'
import { RevocationsStorage } from './types/revocations'

export * from '@web3-storage/capabilities/types'
export * from '@ucanto/interface'
Expand All @@ -132,13 +134,16 @@ export type {
DelegationsStorage,
Query as DelegationsStorageQuery,
} from './types/delegations'
import { RevocationsStorage } from './types/revocations'
export type {
Revocation,
RevocationQuery,
MatchingRevocations,
RevocationsStorage,
} from './types/revocations'
export type { RateLimitsStorage, RateLimit } from './types/rate-limits'
import { PlansStorage } from './types/plans'
export type { PlansStorage } from './types/plans'

export interface Service {
store: {
Expand Down Expand Up @@ -233,6 +238,9 @@ export interface Service {
space: {
info: ServiceMethod<SpaceInfo, SpaceInfoSuccess, SpaceInfoFailure>
}
plan: {
get: ServiceMethod<PlanGet, PlanGetSuccess, PlanGetFailure>
}
}

export type StoreServiceContext = SpaceServiceContext & {
Expand Down Expand Up @@ -304,6 +312,10 @@ export interface RevocationServiceContext {
revocationsStorage: RevocationsStorage
}

export interface PlanServiceContext {
plansStorage: PlansStorage
}

export interface ServiceContext
extends AccessServiceContext,
ConsoleServiceContext,
Expand All @@ -315,6 +327,7 @@ export interface ServiceContext
SubscriptionServiceContext,
RateLimitServiceContext,
RevocationServiceContext,
PlanServiceContext,
UploadServiceContext {}

export interface UcantoServerContext extends ServiceContext, RevocationChecker {
Expand Down
Loading
Loading