From 8db017c94bd28d073996fa1d8436b7ce6eadb29c Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Tue, 24 Oct 2023 13:36:04 -0700 Subject: [PATCH 01/14] feat: implement `plan/get` capability 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. --- packages/capabilities/src/plan.js | 19 ++++ .../test/capabilities/plan.test.js | 94 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 packages/capabilities/src/plan.js create mode 100644 packages/capabilities/test/capabilities/plan.test.js diff --git a/packages/capabilities/src/plan.js b/packages/capabilities/src/plan.js new file mode 100644 index 000000000..a1eb55a31 --- /dev/null +++ b/packages/capabilities/src/plan.js @@ -0,0 +1,19 @@ +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/test/capabilities/plan.test.js b/packages/capabilities/test/capabilities/plan.test.js new file mode 100644 index 000000000..d839f3e0e --- /dev/null +++ b/packages/capabilities/test/capabilities/plan.test.js @@ -0,0 +1,94 @@ +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 () { + 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) + } + }) +}) From d521befd457d7486874db5afc982434f922a13a7 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Tue, 24 Oct 2023 13:48:43 -0700 Subject: [PATCH 02/14] fix: prettier plus export a couple more types --- packages/capabilities/src/plan.js | 5 +---- packages/capabilities/src/types.ts | 15 +++++++++++++- .../test/capabilities/plan.test.js | 20 ++++++++++--------- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/capabilities/src/plan.js b/packages/capabilities/src/plan.js index a1eb55a31..5d8f2cf35 100644 --- a/packages/capabilities/src/plan.js +++ b/packages/capabilities/src/plan.js @@ -11,9 +11,6 @@ export const get = capability({ can: 'plan/get', with: AccountDID, derives: (child, parent) => { - return ( - and(equalWith(child, parent)) || - ok({}) - ) + return and(equalWith(child, parent)) || ok({}) }, }) diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index 2384561bb..08ae2956b 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -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 } @@ -514,6 +515,17 @@ export type AggregateAccept = InferInvokedCapability< typeof DealerCaps.aggregateAccept > export type DealInfo = InferInvokedCapability + +// Plan + +export type PlanGet = InferInvokedCapability +export interface PlanGetSuccess { + updatedAt: string + product: DID +} + +export type PlanGetFailure = never + // Top export type Top = InferInvokedCapability @@ -554,5 +566,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 index d839f3e0e..143ba3dd2 100644 --- a/packages/capabilities/test/capabilities/plan.test.js +++ b/packages/capabilities/test/capabilities/plan.test.js @@ -13,7 +13,7 @@ describe('plan/get', function () { issuer: agent, audience: service, with: account, - proofs: await createAuthorization({ agent, service, account }) + proofs: await createAuthorization({ agent, service, account }), }) const result = await access(await auth.delegate(), { capability: Plan.get, @@ -35,7 +35,7 @@ describe('plan/get', function () { const auth = Plan.get.invoke({ issuer: agent, audience: service, - with: account + with: account, }) const result = await access(await auth.delegate(), { @@ -53,7 +53,7 @@ describe('plan/get', function () { issuer: bob, audience: service, with: account, - proofs: await createAuthorization({ agent, service, account }) + proofs: await createAuthorization({ agent, service, account }), }) const result = await access(await auth.delegate(), { @@ -70,12 +70,14 @@ describe('plan/get', function () { issuer: bob, audience: service, with: account, - proofs: [await Plan.get.delegate({ - issuer: agent, - audience: bob, - with: account, - proofs: await createAuthorization({ agent, service, 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, From fb6a533a9698cc978c8a6737ac685190a48a402d Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Tue, 24 Oct 2023 14:02:01 -0700 Subject: [PATCH 03/14] fix: a couple more additions --- packages/capabilities/src/index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/capabilities/src/index.js b/packages/capabilities/src/index.js index ec0d59bc4..1f7d90beb 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 ] From e3da5e42766a9aec5ac0de671c3cfd2fbe35c331 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Tue, 24 Oct 2023 14:09:39 -0700 Subject: [PATCH 04/14] fix: add an error for plan/get --- packages/capabilities/src/types.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index 08ae2956b..bf16be8d4 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -524,7 +524,11 @@ export interface PlanGetSuccess { product: DID } -export type PlanGetFailure = never +export interface PlanNotFound extends Ucanto.Failure { + name: 'PlanNotFound' +} + +export type PlanGetFailure = PlanNotFound // Top export type Top = InferInvokedCapability From bd60f44e048c24925951dca41649a26699d88d84 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Tue, 24 Oct 2023 15:49:31 -0700 Subject: [PATCH 05/14] feat: define PlansStorage interface plus in-memory implementation and tests --- packages/capabilities/src/index.js | 2 +- packages/upload-api/src/plan/get.js | 18 +++++++ packages/upload-api/src/types.ts | 17 +++++- packages/upload-api/src/types/plans.ts | 28 ++++++++++ packages/upload-api/test/handlers/plan.js | 54 +++++++++++++++++++ .../handlers/{ucan.spec.js => plan.spec.js} | 0 packages/upload-api/test/helpers/context.js | 3 ++ packages/upload-api/test/lib.js | 3 ++ .../test/storage/plans-storage-tests.js | 22 ++++++++ .../upload-api/test/storage/plans-storage.js | 36 +++++++++++++ .../test/storage/plans-storage.spec.js | 29 ++++++++++ .../test/storage/provisions-storage.js | 3 +- 12 files changed, 211 insertions(+), 4 deletions(-) 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 rename packages/upload-api/test/handlers/{ucan.spec.js => plan.spec.js} (100%) 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/capabilities/src/index.js b/packages/capabilities/src/index.js index 1f7d90beb..e38adbd8e 100644 --- a/packages/capabilities/src/index.js +++ b/packages/capabilities/src/index.js @@ -79,5 +79,5 @@ export const abilitiesAsStrings = [ Admin.admin.can, Admin.upload.inspect.can, Admin.store.inspect.can, - Plan.get.can + Plan.get.can, ] 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 015ad2444..c81ed6012 100644 --- a/packages/upload-api/src/types.ts +++ b/packages/upload-api/src/types.ts @@ -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' @@ -132,6 +134,7 @@ export type { DelegationsStorage, Query as DelegationsStorageQuery, } from './types/delegations' +import { RevocationsStorage } from './types/revocations' export type { Revocation, RevocationQuery, @@ -139,6 +142,8 @@ export type { 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: { @@ -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..05bee8b32 --- /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' + +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..7f968468c --- /dev/null +++ b/packages/upload-api/test/handlers/plan.js @@ -0,0 +1,54 @@ +import * as API from '../../src/types.js' +import { alice, bob, mallory } from '../util.js' +import { UCAN, Console } from '@web3-storage/capabilities' + +/** + * @type {API.Tests} + */ +export const test = { + 'issuer can revoke delegation': async (assert, context) => { + const proof = await Console.log.delegate({ + issuer: context.id, + audience: alice, + with: context.id.did(), + }) + + const success = await Console.log + .invoke({ + issuer: alice, + audience: context.id, + with: context.id.did(), + nb: { value: 'hello' }, + proofs: [proof], + }) + .execute(context.connection) + + assert.deepEqual(success.out, { ok: 'hello' }) + + const revoke = await UCAN.revoke + .invoke({ + issuer: context.id, + audience: context.id, + with: context.id.did(), + nb: { + ucan: proof.cid, + }, + proofs: [proof], + }) + .execute(context.connection) + + assert.ok(revoke.out.ok?.time) + + const failure = await Console.log + .invoke({ + issuer: alice, + audience: context.id, + with: context.id.did(), + nb: { value: 'bye' }, + proofs: [proof], + }) + .execute(context.connection) + + assert.ok(failure.out.error?.message.includes('has been revoked')) + }, +} diff --git a/packages/upload-api/test/handlers/ucan.spec.js b/packages/upload-api/test/handlers/plan.spec.js similarity index 100% rename from packages/upload-api/test/handlers/ucan.spec.js rename to packages/upload-api/test/handlers/plan.spec.js 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..31ff16c2b --- /dev/null +++ b/packages/upload-api/test/storage/plans-storage-tests.js @@ -0,0 +1,22 @@ +import * as API from '../../src/types.js' +import * as Types from '../types.js' +import * as principal from '@ucanto/principal' +import { Provider } from '@web3-storage/capabilities' + +/** + * @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..d1a2c02d6 --- /dev/null +++ b/packages/upload-api/test/storage/plans-storage.js @@ -0,0 +1,36 @@ +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) { + return { ok: this.plans[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} From e9985c12a9bab5ebff342fcd374d2c361dc8b295 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Tue, 24 Oct 2023 15:53:44 -0700 Subject: [PATCH 06/14] feat: add plan service to top level service --- packages/upload-api/src/lib.js | 2 ++ packages/upload-api/src/plan.js | 9 +++++++++ 2 files changed, 11 insertions(+) create mode 100644 packages/upload-api/src/plan.js 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), +}) From 956ecc48ac20bb9a65ab6e55ff62c935211c635b Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Tue, 24 Oct 2023 15:57:13 -0700 Subject: [PATCH 07/14] fix: remove unused vars --- packages/upload-api/test/handlers/plan.js | 2 +- packages/upload-api/test/storage/plans-storage-tests.js | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/upload-api/test/handlers/plan.js b/packages/upload-api/test/handlers/plan.js index 7f968468c..1ff88c56f 100644 --- a/packages/upload-api/test/handlers/plan.js +++ b/packages/upload-api/test/handlers/plan.js @@ -1,5 +1,5 @@ import * as API from '../../src/types.js' -import { alice, bob, mallory } from '../util.js' +import { alice } from '../util.js' import { UCAN, Console } from '@web3-storage/capabilities' /** diff --git a/packages/upload-api/test/storage/plans-storage-tests.js b/packages/upload-api/test/storage/plans-storage-tests.js index 31ff16c2b..d07ed0f06 100644 --- a/packages/upload-api/test/storage/plans-storage-tests.js +++ b/packages/upload-api/test/storage/plans-storage-tests.js @@ -1,7 +1,4 @@ import * as API from '../../src/types.js' -import * as Types from '../types.js' -import * as principal from '@ucanto/principal' -import { Provider } from '@web3-storage/capabilities' /** * @type {API.Tests} From ba2132061ae7b0a8c1ad6f95514787d95dc47428 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Tue, 24 Oct 2023 17:06:42 -0700 Subject: [PATCH 08/14] feat: add function to access-client to get account plan plus a tweak to plans-storage to return errors --- packages/access-client/src/agent-use-cases.js | 14 ++++- .../test/agent-use-cases.test.js | 51 +++++++++++++++++++ packages/capabilities/package.json | 4 ++ .../upload-api/test/storage/plans-storage.js | 12 ++++- 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/packages/access-client/src/agent-use-cases.js b/packages/access-client/src/agent-use-cases.js index 37084bd6c..e1861e2ca 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/src/types.js').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..35d758d5f 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,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} */ + 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 () { + assert((await getAccountPlan(agent, accountWithoutAPlan)).error) + }) +}) diff --git a/packages/capabilities/package.json b/packages/capabilities/package.json index 056243cf6..8a8ade696 100644 --- a/packages/capabilities/package.json +++ b/packages/capabilities/package.json @@ -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" diff --git a/packages/upload-api/test/storage/plans-storage.js b/packages/upload-api/test/storage/plans-storage.js index d1a2c02d6..d8db50043 100644 --- a/packages/upload-api/test/storage/plans-storage.js +++ b/packages/upload-api/test/storage/plans-storage.js @@ -17,7 +17,17 @@ export class PlansStorage { * @returns */ async get(account) { - return { ok: this.plans[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}` + } + } + } } /** From 515d39fad93861fb2cf28062f0d6b854fbe7c1e4 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Wed, 25 Oct 2023 09:36:40 -0700 Subject: [PATCH 09/14] fixed: prettier --- packages/access-client/src/agent-use-cases.js | 8 +-- .../test/agent-use-cases.test.js | 51 +++++++++++-------- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/access-client/src/agent-use-cases.js b/packages/access-client/src/agent-use-cases.js index e1861e2ca..58c087c09 100644 --- a/packages/access-client/src/agent-use-cases.js +++ b/packages/access-client/src/agent-use-cases.js @@ -332,13 +332,13 @@ async function createIssuerSaysAccountCanAdminSpace( } /** - * - * @param {AccessAgent} agent + * + * @param {AccessAgent} agent * @param {import('@web3-storage/did-mailto/src/types.js').DidMailto} account */ -export async function getAccountPlan(agent, account){ +export async function getAccountPlan(agent, account) { const receipt = await agent.invokeAndExecute(Plan.get, { - with: account + 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 35d758d5f..417d38509 100644 --- a/packages/access-client/test/agent-use-cases.test.js +++ b/packages/access-client/test/agent-use-cases.test.js @@ -201,24 +201,22 @@ describe('getAccountPlan', async function () { const plans = { [accountWithAPlan]: { product: 'did:web:test.web3.storage', - updatedAt: new Date().toISOString() - } + 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: '' + return plan + ? { ok: plan } + : { + error: { + name: 'PlanNotFound', + message: '', + }, } - } - } }), }, }) @@ -226,16 +224,29 @@ describe('getAccountPlan', async function () { 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) + 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 () { - assert((await getAccountPlan(agent, accountWithoutAPlan)).error) + 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') }) }) From 7a01a706381916dd6ea314c1c868de62c27cf697 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Wed, 25 Oct 2023 10:16:50 -0700 Subject: [PATCH 10/14] feat: implement plan/get handler tests I copied them from the ucan tests and neglected to actually implement them! --- packages/upload-api/test/handlers/plan.js | 64 +++++++------------ .../upload-api/test/handlers/plan.spec.js | 6 +- .../upload-api/test/handlers/ucan.spec.js | 30 +++++++++ 3 files changed, 55 insertions(+), 45 deletions(-) create mode 100644 packages/upload-api/test/handlers/ucan.spec.js diff --git a/packages/upload-api/test/handlers/plan.js b/packages/upload-api/test/handlers/plan.js index 1ff88c56f..16b86aea3 100644 --- a/packages/upload-api/test/handlers/plan.js +++ b/packages/upload-api/test/handlers/plan.js @@ -1,54 +1,34 @@ import * as API from '../../src/types.js' +import { createServer, connect } from '../../src/lib.js' import { alice } from '../util.js' -import { UCAN, Console } from '@web3-storage/capabilities' +import { Plan } from '@web3-storage/capabilities' +import { createAuthorization } from '../helpers/utils.js' +import { Absentee } from '@ucanto/principal' /** * @type {API.Tests} */ export const test = { - 'issuer can revoke delegation': async (assert, context) => { - const proof = await Console.log.delegate({ - issuer: context.id, - audience: alice, - with: context.id.did(), + '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 success = await Console.log - .invoke({ - issuer: alice, - audience: context.id, - with: context.id.did(), - nb: { value: 'hello' }, - proofs: [proof], - }) - .execute(context.connection) - - assert.deepEqual(success.out, { ok: 'hello' }) - - const revoke = await UCAN.revoke - .invoke({ - issuer: context.id, - audience: context.id, - with: context.id.did(), - nb: { - ucan: proof.cid, - }, - proofs: [proof], - }) - .execute(context.connection) - - assert.ok(revoke.out.ok?.time) - - const failure = await Console.log - .invoke({ - issuer: alice, - audience: context.id, - with: context.id.did(), - nb: { value: 'bye' }, - proofs: [proof], + 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(context.connection) + }).execute(connection) - assert.ok(failure.out.error?.message.includes('has been revoked')) + assert.ok(result.out.ok) + assert.equal(result.out.ok?.product, product) }, } diff --git a/packages/upload-api/test/handlers/plan.spec.js b/packages/upload-api/test/handlers/plan.spec.js index 85ad63384..08cb929e3 100644 --- a/packages/upload-api/test/handlers/plan.spec.js +++ b/packages/upload-api/test/handlers/plan.spec.js @@ -1,10 +1,10 @@ /* eslint-disable no-only-tests/no-only-tests */ -import * as UCAN from './ucan.js' +import * as Plan from './plan.js' import * as assert from 'assert' import { cleanupContext, createContext } from '../helpers/context.js' -describe('ucan/*', () => { - for (const [name, test] of Object.entries(UCAN.test)) { +describe('plan/*', () => { + for (const [name, test] of Object.entries(Plan.test)) { const define = name.startsWith('only ') ? it.only : name.startsWith('skip ') diff --git a/packages/upload-api/test/handlers/ucan.spec.js b/packages/upload-api/test/handlers/ucan.spec.js new file mode 100644 index 000000000..85ad63384 --- /dev/null +++ b/packages/upload-api/test/handlers/ucan.spec.js @@ -0,0 +1,30 @@ +/* eslint-disable no-only-tests/no-only-tests */ +import * as UCAN from './ucan.js' +import * as assert from 'assert' +import { cleanupContext, createContext } from '../helpers/context.js' + +describe('ucan/*', () => { + for (const [name, test] of Object.entries(UCAN.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) + } + }) + } +}) From 74f432835f2d389468eb1a963a08da02eed59e7a Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Wed, 25 Oct 2023 10:28:34 -0700 Subject: [PATCH 11/14] fix: typo --- packages/capabilities/test/capabilities/plan.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/capabilities/test/capabilities/plan.test.js b/packages/capabilities/test/capabilities/plan.test.js index 143ba3dd2..2e7055abf 100644 --- a/packages/capabilities/test/capabilities/plan.test.js +++ b/packages/capabilities/test/capabilities/plan.test.js @@ -65,7 +65,7 @@ describe('plan/get', function () { assert.equal(result.error?.message.includes('not authorized'), true) }) - it('can delegate plan/add', async function () { + it('can delegate plan/get', async function () { const invocation = Plan.get.invoke({ issuer: bob, audience: service, From 0f3c6d203046c531ff90064dbf1c7146758281a5 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Wed, 25 Oct 2023 10:30:46 -0700 Subject: [PATCH 12/14] fix: missing file extension --- packages/upload-api/src/types/plans.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/upload-api/src/types/plans.ts b/packages/upload-api/src/types/plans.ts index 05bee8b32..b5a29507a 100644 --- a/packages/upload-api/src/types/plans.ts +++ b/packages/upload-api/src/types/plans.ts @@ -1,5 +1,5 @@ import * as Ucanto from '@ucanto/interface' -import { AccountDID, DID, PlanGetFailure, PlanGetSuccess } from '../types' +import { AccountDID, DID, PlanGetFailure, PlanGetSuccess } from '../types.js' export type PlanID = DID From 34d1b015a75ca1fb5316ddaf07ddc3bd0be98eda Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Wed, 25 Oct 2023 10:49:04 -0700 Subject: [PATCH 13/14] feat: alias string to ISO8601Date for clarity We are already expecting ISO8601 dates in these fields, so per feedback in https://github.com/web3-storage/w3up/pull/1005#discussion_r1372073212 I'm creating a new ISO8601Date type aliased to `string` to better document that expectation. We could get even more clever here, but I'd like to minimize changes while also moving toward a slightly better situation here, so this feels like a good compromise. --- packages/capabilities/src/types.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index bf16be8d4..4a207e3be 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -35,6 +35,8 @@ 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 } /** @@ -395,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? @@ -520,7 +522,7 @@ export type DealInfo = InferInvokedCapability export type PlanGet = InferInvokedCapability export interface PlanGetSuccess { - updatedAt: string + updatedAt: ISO8601Date product: DID } From 95a1caaec7cdfb5ace959daf9770f8db8326fdd2 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Wed, 25 Oct 2023 11:11:46 -0700 Subject: [PATCH 14/14] chore: add test to ensure date is kept in 8601 format --- packages/upload-api/test/handlers/plan.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/upload-api/test/handlers/plan.js b/packages/upload-api/test/handlers/plan.js index 16b86aea3..a5c3a5df7 100644 --- a/packages/upload-api/test/handlers/plan.js +++ b/packages/upload-api/test/handlers/plan.js @@ -30,5 +30,8 @@ export const test = { 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) }, }