From f0456d2e2aab462666810e22abd7dfb7e1ce21be Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Thu, 26 Oct 2023 11:34:12 -0700 Subject: [PATCH] feat: implement `plan/get` capability (#1005) In console, we need a way to tell if a user has a subscription. Implement the `plan/get` capability from https://github.com/web3-storage/w3up/issues/959 to enable that. Includes the core capability definitions plus `upload-api` handlers and `access-client` helper functions. --- packages/access-client/src/agent-use-cases.js | 14 ++- .../test/agent-use-cases.test.js | 62 ++++++++++++ packages/capabilities/package.json | 4 + packages/capabilities/src/index.js | 3 + packages/capabilities/src/plan.js | 16 ++++ packages/capabilities/src/types.ts | 27 +++++- .../test/capabilities/plan.test.js | 96 +++++++++++++++++++ packages/upload-api/src/lib.js | 2 + packages/upload-api/src/plan.js | 9 ++ packages/upload-api/src/plan/get.js | 18 ++++ packages/upload-api/src/types.ts | 15 ++- packages/upload-api/src/types/plans.ts | 28 ++++++ packages/upload-api/test/handlers/plan.js | 37 +++++++ .../upload-api/test/handlers/plan.spec.js | 30 ++++++ packages/upload-api/test/helpers/context.js | 3 + packages/upload-api/test/lib.js | 3 + .../test/storage/plans-storage-tests.js | 19 ++++ .../upload-api/test/storage/plans-storage.js | 46 +++++++++ .../test/storage/plans-storage.spec.js | 29 ++++++ .../test/storage/provisions-storage.js | 3 +- 20 files changed, 457 insertions(+), 7 deletions(-) create mode 100644 packages/capabilities/src/plan.js create mode 100644 packages/capabilities/test/capabilities/plan.test.js create mode 100644 packages/upload-api/src/plan.js create mode 100644 packages/upload-api/src/plan/get.js create mode 100644 packages/upload-api/src/types/plans.ts create mode 100644 packages/upload-api/test/handlers/plan.js create mode 100644 packages/upload-api/test/handlers/plan.spec.js create mode 100644 packages/upload-api/test/storage/plans-storage-tests.js create mode 100644 packages/upload-api/test/storage/plans-storage.js create mode 100644 packages/upload-api/test/storage/plans-storage.spec.js diff --git a/packages/access-client/src/agent-use-cases.js b/packages/access-client/src/agent-use-cases.js index 37084bd6c..888a76f56 100644 --- a/packages/access-client/src/agent-use-cases.js +++ b/packages/access-client/src/agent-use-cases.js @@ -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' @@ -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 +} diff --git a/packages/access-client/test/agent-use-cases.test.js b/packages/access-client/test/agent-use-cases.test.js index 55063886a..417d38509 100644 --- a/packages/access-client/test/agent-use-cases.test.js +++ b/packages/access-client/test/agent-use-cases.test.js @@ -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' @@ -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} */ + 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') + }) +}) diff --git a/packages/capabilities/package.json b/packages/capabilities/package.json index c6d9854db..c68ee50b8 100644 --- a/packages/capabilities/package.json +++ b/packages/capabilities/package.json @@ -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" diff --git a/packages/capabilities/src/index.js b/packages/capabilities/src/index.js index ec0d59bc4..e38adbd8e 100644 --- a/packages/capabilities/src/index.js +++ b/packages/capabilities/src/index.js @@ -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, @@ -38,6 +39,7 @@ export { DealTracker, Admin, UCAN, + Plan, } /** @type {import('./types.js').AbilitiesArray} */ @@ -77,4 +79,5 @@ export const abilitiesAsStrings = [ Admin.admin.can, Admin.upload.inspect.can, Admin.store.inspect.can, + Plan.get.can, ] diff --git a/packages/capabilities/src/plan.js b/packages/capabilities/src/plan.js new file mode 100644 index 000000000..5d8f2cf35 --- /dev/null +++ b/packages/capabilities/src/plan.js @@ -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({}) + }, +}) diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index 2384561bb..4a207e3be 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -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 } @@ -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? @@ -514,6 +517,21 @@ export type AggregateAccept = InferInvokedCapability< typeof DealerCaps.aggregateAccept > export type DealInfo = InferInvokedCapability + +// Plan + +export type PlanGet = InferInvokedCapability +export interface PlanGetSuccess { + updatedAt: ISO8601Date + product: DID +} + +export interface PlanNotFound extends Ucanto.Failure { + name: 'PlanNotFound' +} + +export type PlanGetFailure = PlanNotFound + // Top export type Top = InferInvokedCapability @@ -554,5 +572,6 @@ export type AbilitiesArray = [ DealInfo['can'], Admin['can'], AdminUploadInspect['can'], - AdminStoreInspect['can'] + AdminStoreInspect['can'], + PlanGet['can'] ] diff --git a/packages/capabilities/test/capabilities/plan.test.js b/packages/capabilities/test/capabilities/plan.test.js new file mode 100644 index 000000000..2e7055abf --- /dev/null +++ b/packages/capabilities/test/capabilities/plan.test.js @@ -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) + } + }) +}) diff --git a/packages/upload-api/src/lib.js b/packages/upload-api/src/lib.js index efebbb83f..36afd12b9 100644 --- a/packages/upload-api/src/lib.js +++ b/packages/upload-api/src/lib.js @@ -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' @@ -48,6 +49,7 @@ export const createService = (context) => ({ subscription: createSubscriptionService(context), upload: createUploadService(context), ucan: createUcanService(context), + plan: createPlanService(context), }) /** diff --git a/packages/upload-api/src/plan.js b/packages/upload-api/src/plan.js new file mode 100644 index 000000000..0f22c7b3c --- /dev/null +++ b/packages/upload-api/src/plan.js @@ -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), +}) diff --git a/packages/upload-api/src/plan/get.js b/packages/upload-api/src/plan/get.js new file mode 100644 index 000000000..d146aa55f --- /dev/null +++ b/packages/upload-api/src/plan/get.js @@ -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} input + * @param {API.PlanServiceContext} context + * @returns {Promise>} + */ +const get = async ({ capability }, context) => { + return context.plansStorage.get(capability.with) +} diff --git a/packages/upload-api/src/types.ts b/packages/upload-api/src/types.ts index 87ba15fd4..555b4a6de 100644 --- a/packages/upload-api/src/types.ts +++ b/packages/upload-api/src/types.ts @@ -113,13 +113,16 @@ 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.js' @@ -139,6 +142,8 @@ export type { RevocationsStorage, } from './types/revocations.js' export type { RateLimitsStorage, RateLimit } from './types/rate-limits.js' +import { PlansStorage } from './types/plans.js' +export type { PlansStorage } from './types/plans.js' export interface Service { store: { @@ -233,6 +238,9 @@ export interface Service { space: { info: ServiceMethod } + plan: { + get: ServiceMethod + } } export type StoreServiceContext = SpaceServiceContext & { @@ -304,6 +312,10 @@ export interface RevocationServiceContext { revocationsStorage: RevocationsStorage } +export interface PlanServiceContext { + plansStorage: PlansStorage +} + export interface ServiceContext extends AccessServiceContext, ConsoleServiceContext, @@ -315,6 +327,7 @@ export interface ServiceContext SubscriptionServiceContext, RateLimitServiceContext, RevocationServiceContext, + PlanServiceContext, UploadServiceContext {} export interface UcantoServerContext extends ServiceContext, RevocationChecker { diff --git a/packages/upload-api/src/types/plans.ts b/packages/upload-api/src/types/plans.ts new file mode 100644 index 000000000..b5a29507a --- /dev/null +++ b/packages/upload-api/src/types/plans.ts @@ -0,0 +1,28 @@ +import * as Ucanto from '@ucanto/interface' +import { AccountDID, DID, PlanGetFailure, PlanGetSuccess } from '../types.js' + +export type PlanID = DID + +/** + * Stores subscription plan information. + */ +export interface PlansStorage { + /** + * Get plan information for an account + * + * @param account account DID + */ + get: ( + account: AccountDID + ) => Promise> + + /** + * Set an account's plan + * + * @param account account DID + */ + set: ( + account: AccountDID, + plan: DID + ) => Promise> +} diff --git a/packages/upload-api/test/handlers/plan.js b/packages/upload-api/test/handlers/plan.js new file mode 100644 index 000000000..a5c3a5df7 --- /dev/null +++ b/packages/upload-api/test/handlers/plan.js @@ -0,0 +1,37 @@ +import * as API from '../../src/types.js' +import { createServer, connect } from '../../src/lib.js' +import { alice } from '../util.js' +import { Plan } from '@web3-storage/capabilities' +import { createAuthorization } from '../helpers/utils.js' +import { Absentee } from '@ucanto/principal' + +/** + * @type {API.Tests} + */ +export const test = { + 'an account can get plan information': async (assert, context) => { + const account = 'did:mailto:example.com:alice' + const product = 'did:web:test.web3.storage' + context.plansStorage.set(account, product) + const connection = connect({ + id: context.id, + channel: createServer(context), + }) + const result = await Plan.get.invoke({ + issuer: alice, + audience: context.service, + with: account, + proofs: await createAuthorization({ + agent: alice, + account: Absentee.from({ id: account }), + service: context.service + }) + }).execute(connection) + + assert.ok(result.out.ok) + assert.equal(result.out.ok?.product, product) + assert.ok(result.out.ok?.updatedAt) + const date = /** @type {string} */(result.out.ok?.updatedAt) + assert.equal(new Date(Date.parse(date)).toISOString(), date) + }, +} diff --git a/packages/upload-api/test/handlers/plan.spec.js b/packages/upload-api/test/handlers/plan.spec.js new file mode 100644 index 000000000..08cb929e3 --- /dev/null +++ b/packages/upload-api/test/handlers/plan.spec.js @@ -0,0 +1,30 @@ +/* eslint-disable no-only-tests/no-only-tests */ +import * as Plan from './plan.js' +import * as assert from 'assert' +import { cleanupContext, createContext } from '../helpers/context.js' + +describe('plan/*', () => { + for (const [name, test] of Object.entries(Plan.test)) { + const define = name.startsWith('only ') + ? it.only + : name.startsWith('skip ') + ? it.skip + : it + + define(name, async () => { + const context = await createContext() + try { + await test( + { + equal: assert.strictEqual, + deepEqual: assert.deepStrictEqual, + ok: assert.ok, + }, + context + ) + } finally { + await cleanupContext(context) + } + }) + } +}) diff --git a/packages/upload-api/test/helpers/context.js b/packages/upload-api/test/helpers/context.js index 209efe887..02812792b 100644 --- a/packages/upload-api/test/helpers/context.js +++ b/packages/upload-api/test/helpers/context.js @@ -13,6 +13,7 @@ import { create as createRevocationChecker } from '../../src/utils/revocation.js import { createServer, connect } from '../../src/lib.js' import * as Types from '../../src/types.js' import * as TestTypes from '../types.js' +import { PlansStorage } from '../storage/plans-storage.js' /** * @param {object} options @@ -25,6 +26,7 @@ export const createContext = async (options = {}) => { const carStoreBucket = await CarStoreBucket.activate() const dudewhereBucket = new DudewhereBucket() const revocationsStorage = new RevocationsStorage() + const plansStorage = new PlansStorage() const signer = await Signer.generate() const id = signer.withDID('did:web:test.web3.storage') @@ -37,6 +39,7 @@ export const createContext = async (options = {}) => { provisionsStorage: new ProvisionsStorage(options.providers), delegationsStorage: new DelegationsStorage(), rateLimitsStorage: new RateLimitsStorage(), + plansStorage, revocationsStorage, errorReporter: { catch(error) { diff --git a/packages/upload-api/test/lib.js b/packages/upload-api/test/lib.js index 8e16e7596..bd776f432 100644 --- a/packages/upload-api/test/lib.js +++ b/packages/upload-api/test/lib.js @@ -12,6 +12,7 @@ import { test as delegationsStorageTests } from './storage/delegations-storage-t import { test as provisionsStorageTests } from './storage/provisions-storage-tests.js' import { test as rateLimitsStorageTests } from './storage/rate-limits-storage-tests.js' import { test as revocationsStorageTests } from './storage/revocations-storage-tests.js' +import { test as plansStorageTests } from './storage/plans-storage-tests.js' import { DebugEmail } from '../src/utils/email.js' export * from './util.js' @@ -26,6 +27,7 @@ export const storageTests = { ...provisionsStorageTests, ...rateLimitsStorageTests, ...revocationsStorageTests, + ...plansStorageTests, } export const handlerTests = { @@ -48,5 +50,6 @@ export { provisionsStorageTests, rateLimitsStorageTests, revocationsStorageTests, + plansStorageTests, DebugEmail, } diff --git a/packages/upload-api/test/storage/plans-storage-tests.js b/packages/upload-api/test/storage/plans-storage-tests.js new file mode 100644 index 000000000..d07ed0f06 --- /dev/null +++ b/packages/upload-api/test/storage/plans-storage-tests.js @@ -0,0 +1,19 @@ +import * as API from '../../src/types.js' + +/** + * @type {API.Tests} + */ +export const test = { + 'should persist plans': async (assert, context) => { + const storage = context.plansStorage + + const account = 'did:mailto:example.com:alice' + const product = 'did:web:free.web3.storage' + const setResult = await storage.set(account, product) + + assert.ok(setResult.ok) + + const getResult = await storage.get(account) + assert.equal(getResult.ok?.product, product) + }, +} diff --git a/packages/upload-api/test/storage/plans-storage.js b/packages/upload-api/test/storage/plans-storage.js new file mode 100644 index 000000000..d8db50043 --- /dev/null +++ b/packages/upload-api/test/storage/plans-storage.js @@ -0,0 +1,46 @@ +import * as Types from '../../src/types.js' + +/** + * @implements {Types.PlansStorage} + */ +export class PlansStorage { + constructor() { + /** + * @type {Record} + */ + this.plans = {} + } + + /** + * + * @param {Types.DID} account + * @returns + */ + async get(account) { + const plan = this.plans[account] + if (plan) { + return { ok: this.plans[account] } + } else { + return { + error: { + name: /** @type {const} */ ('PlanNotFound'), + message: `could not find a plan for ${account}` + } + } + } + } + + /** + * + * @param {Types.DID} account + * @param {Types.DID} product + * @returns + */ + async set(account, product) { + this.plans[account] = { + product, + updatedAt: new Date().toISOString(), + } + return { ok: {} } + } +} diff --git a/packages/upload-api/test/storage/plans-storage.spec.js b/packages/upload-api/test/storage/plans-storage.spec.js new file mode 100644 index 000000000..0efcf7661 --- /dev/null +++ b/packages/upload-api/test/storage/plans-storage.spec.js @@ -0,0 +1,29 @@ +import * as PlansStorage from './plans-storage-tests.js' +import * as assert from 'assert' +import { cleanupContext, createContext } from '../helpers/context.js' + +describe('in memory plans storage', async () => { + for (const [name, test] of Object.entries(PlansStorage.test)) { + const define = name.startsWith('only ') + ? it.only + : name.startsWith('skip ') + ? it.skip + : it + + define(name, async () => { + const context = await createContext() + try { + await test( + { + equal: assert.strictEqual, + deepEqual: assert.deepStrictEqual, + ok: assert.ok, + }, + context + ) + } finally { + await cleanupContext(context) + } + }) + } +}) diff --git a/packages/upload-api/test/storage/provisions-storage.js b/packages/upload-api/test/storage/provisions-storage.js index 143fc8d75..272c44a7a 100644 --- a/packages/upload-api/test/storage/provisions-storage.js +++ b/packages/upload-api/test/storage/provisions-storage.js @@ -5,7 +5,8 @@ import * as Types from '../../src/types.js' * @param {Types.Provision} item * @returns {string} */ -const itemKey = ({customer, consumer, provider}) => `${customer}:${consumer}@${provider}` +const itemKey = ({ customer, consumer, provider }) => + `${customer}:${consumer}@${provider}` /** * @implements {Types.ProvisionsStorage}