From 1b94f2d3f6538d717d38b21dcb76657fd1f3e268 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Wed, 15 Nov 2023 17:18:46 +0300 Subject: [PATCH] feat!: coupon (#1136) --- packages/access-client/src/access.js | 24 ++- packages/access-client/src/agent-use-cases.js | 5 +- packages/access-client/src/space.js | 51 +++++- packages/capabilities/readme.md | 1 - packages/w3up-client/src/account.js | 1 + packages/w3up-client/src/client.js | 2 + packages/w3up-client/src/coupon.js | 148 ++++++++++++++++++ packages/w3up-client/test/access.test.js | 1 - packages/w3up-client/test/coupon.test.js | 90 +++++++++++ 9 files changed, 299 insertions(+), 24 deletions(-) create mode 100644 packages/w3up-client/src/coupon.js create mode 100644 packages/w3up-client/test/coupon.test.js diff --git a/packages/access-client/src/access.js b/packages/access-client/src/access.js index 16845c385..33e91024c 100644 --- a/packages/access-client/src/access.js +++ b/packages/access-client/src/access.js @@ -117,8 +117,12 @@ export const claim = async ( return result } else { const delegations = Object.values(result.ok.delegations) - const proofs = delegations.flatMap((proof) => bytesToDelegations(proof)) - return { ok: new GrantedAccess({ agent, provider, audience, proofs }) } + + const proofs = /** @type {API.Tuple} */ ( + delegations.flatMap((proof) => bytesToDelegations(proof)) + ) + + return { ok: new GrantedAccess({ agent, proofs }) } } } @@ -208,9 +212,7 @@ class PendingAccessRequest { return { ok: new GrantedAccess({ agent: this.agent, - provider: this.provider, - audience: this.audience, - proofs: result.ok, + proofs: /** @type {API.Tuple} */ (result.ok), }), } } @@ -255,13 +257,11 @@ class RequestExpired extends Failure { /** * View over the UCAN Delegations that grant access to a specific principal. */ -class GrantedAccess { +export class GrantedAccess { /** * @typedef {object} GrantedAccessModel * @property {API.Agent} agent - Agent that processed the request. - * @property {API.DID} audience - Principal access was granted to. - * @property {API.Delegation[]} proofs - Delegations that grant access. - * @property {API.ProviderDID} provider - Provider that handled the request. + * @property {API.Tuple} proofs - Delegations that grant access. * * @param {GrantedAccessModel} model */ @@ -271,12 +271,6 @@ class GrantedAccess { get proofs() { return this.model.proofs } - get provider() { - return this.model.provider - } - get authority() { - return this.model.audience - } /** * Saves access into the agents proofs store so that it can be retained diff --git a/packages/access-client/src/agent-use-cases.js b/packages/access-client/src/agent-use-cases.js index fe37f9df1..499f49021 100644 --- a/packages/access-client/src/agent-use-cases.js +++ b/packages/access-client/src/agent-use-cases.js @@ -217,9 +217,8 @@ export async function authorizeWaitAndClaim(accessAgent, email, opts) { } /** - * Invokes voucher/redeem for the free tier, wait on the websocket for the voucher/claim and invokes it - * - * It also adds a full space delegation to the service in the voucher/claim invocation to allow for recovery + * Provisions space with the specified account and sets up a recovery with the + * same account. * * @param {AccessAgent} access * @param {AgentData} agentData diff --git a/packages/access-client/src/space.js b/packages/access-client/src/space.js index 21600da17..bc16dc733 100644 --- a/packages/access-client/src/space.js +++ b/packages/access-client/src/space.js @@ -1,9 +1,10 @@ import * as ED25519 from '@ucanto/principal/ed25519' -import { delegate, Schema, UCAN } from '@ucanto/core' +import { delegate, Schema, UCAN, error, fail } from '@ucanto/core' import * as BIP39 from '@scure/bip39' import { wordlist } from '@scure/bip39/wordlists/english' import * as API from './types.js' import * as Access from './access.js' +import * as Provider from './provider.js' /** * Data model for the (owned) space. @@ -170,9 +171,7 @@ class OwnedSpace { */ async save({ agent = this.model.agent } = {}) { if (!agent) { - return { - error: new Error('Please provide an agent to save the space into'), - } + return fail('Please provide an agent to save the space into') } const proof = await createAuthorization(this, { agent }) @@ -182,6 +181,19 @@ class OwnedSpace { return { ok: {} } } + /** + * @param {Authorization} authorization + * @param {object} options + * @param {API.Agent} [options.agent] + */ + provision({ proofs }, { agent = this.model.agent } = {}) { + if (!agent) { + return fail('Please provide an agent to save the space into') + } + + return provision(this, { proofs, agent }) + } + /** * Creates a (UCAN) delegation that gives full access to the space to the * specified `account`. At the moment we only allow `did:mailto` principal @@ -243,6 +255,37 @@ export const fromDelegation = (delegation) => { return new SharedSpace({ id: result.ok, delegation, meta }) } +/** + * @typedef {object} Authorization + * @property {API.Delegation[]} proofs + * + * @typedef {object} Space + * @property {() => API.SpaceDID} did + */ + +/** + * @param {Space} space + * @param {object} options + * @param {API.Delegation[]} options.proofs + * @param {API.Agent} options.agent + */ +export const provision = async (space, { proofs, agent }) => { + const [capability] = proofs[0].capabilities + + const { ok: account, error: reason } = Provider.AccountDID.read( + capability.with + ) + if (reason) { + return error(reason) + } + + return await Provider.add(agent, { + consumer: space.did(), + account, + proofs, + }) +} + /** * Represents a shared space, meaning a space for which we have a delegation * and consequently have limited authority over. diff --git a/packages/capabilities/readme.md b/packages/capabilities/readme.md index 64c2f93a0..199bb4b0d 100644 --- a/packages/capabilities/readme.md +++ b/packages/capabilities/readme.md @@ -29,7 +29,6 @@ import * as Top from '@web3-storage/capabilities/top' import * as Types from '@web3-storage/capabilities/types' import * as Upload from '@web3-storage/capabilities/upload' import * as Utils from '@web3-storage/capabilities/utils' -import * as Voucher from '@web3-storage/capabilities/voucher' import * as Filecoin from '@web3-storage/capabilities/filecoin' import * as Aggregator from '@web3-storage/capabilities/filecoin/aggregator' import * as DealTracker from '@web3-storage/capabilities/filecoin/deal-tracker' diff --git a/packages/w3up-client/src/account.js b/packages/w3up-client/src/account.js index 82f2bb23f..597e82aba 100644 --- a/packages/w3up-client/src/account.js +++ b/packages/w3up-client/src/account.js @@ -146,6 +146,7 @@ export class Account { * @param {API.SpaceDID} space * @param {object} input * @param {API.ProviderDID} [input.provider] + * @param {API.Agent} [input.agent] */ provision(space, input = {}) { return provision(this.agent, { diff --git a/packages/w3up-client/src/client.js b/packages/w3up-client/src/client.js index 577da1563..f9d71abe1 100644 --- a/packages/w3up-client/src/client.js +++ b/packages/w3up-client/src/client.js @@ -18,6 +18,7 @@ import { SubscriptionClient } from './capability/subscription.js' import { UsageClient } from './capability/usage.js' import { AccessClient } from './capability/access.js' import { FilecoinClient } from './capability/filecoin.js' +import { CouponAPI } from './coupon.js' export * as Access from './capability/access.js' import * as Result from './result.js' @@ -48,6 +49,7 @@ export class Client extends Base { upload: new UploadClient(agentData, options), usage: new UsageClient(agentData, options), } + this.coupon = new CouponAPI(agentData, options) } did() { diff --git a/packages/w3up-client/src/coupon.js b/packages/w3up-client/src/coupon.js new file mode 100644 index 000000000..0ca379c0d --- /dev/null +++ b/packages/w3up-client/src/coupon.js @@ -0,0 +1,148 @@ +import * as API from '@web3-storage/access/types' +import { sha256, delegate, Delegation } from '@ucanto/core' +import { ed25519 } from '@ucanto/principal' +import * as Result from './result.js' +import { GrantedAccess } from '@web3-storage/access/access' +import { Base } from './base.js' + +export class CouponAPI extends Base { + /** + * Redeems coupon from the the the archive. Throws an error if the coupon + * password is invalid or if provided archive is not a valid. + * + * @param {Uint8Array} archive + * @param {object} [options] + * @param {string} [options.password] + */ + async redeem(archive, options = {}) { + const { agent } = this + const coupon = Result.unwrap(await extract(archive)) + return Result.unwrap(await redeem(coupon, { ...options, agent })) + } + + /** + * Issues a coupon for the given delegation. + * + * @param {Omit} options + */ + async issue({ proofs = [], ...options }) { + const { agent } = this + return await issue({ + ...options, + issuer: agent.issuer, + proofs: [...proofs, ...agent.proofs(options.capabilities)], + }) + } +} + +/** + * Extracts coupon from the archive. + * + * @param {Uint8Array} archive + * @returns {Promise>} + */ +export const extract = async (archive) => { + const { ok, error } = await Delegation.extract(archive) + return ok ? Result.ok(new Coupon({ proofs: [ok] })) : Result.error(error) +} + +/** + * Encodes coupon into an archive. + * + * @param {Model} coupon + */ +export const archive = async (coupon) => { + const [delegation] = coupon.proofs + return await Delegation.archive(delegation) +} + +/** + * Issues a coupon for the given delegation. + * + * @typedef {Omit, 'audience'> & { password?: string }} CouponOptions + * @param {CouponOptions} options + */ +export const issue = async ({ password = '', ...options }) => { + const audience = await deriveSigner(password) + const delegation = await delegate({ + ...options, + audience, + }) + + return new Coupon({ proofs: [delegation] }) +} + +/** + * @typedef {object} Model + * @property {[API.Delegation]} proofs + */ + +/** + * Redeems granted access with the given agent from the given coupon. + * + * @param {Model} coupon + * @param {object} options + * @param {API.Agent} options.agent + * @param {string} [options.password] + * @returns {Promise>} + */ +export const redeem = async (coupon, { agent, password = '' }) => { + const audience = await deriveSigner(password) + const [delegation] = coupon.proofs + + if (delegation.audience.did() !== audience.did()) { + return Result.error( + new RangeError( + password === '' + ? 'Extracting account requires a password' + : 'Provided password is invalid' + ) + ) + } else { + const authorization = await delegate({ + issuer: audience, + audience: agent, + capabilities: delegation.capabilities, + expiration: delegation.expiration, + notBefore: delegation.notBefore, + proofs: [delegation], + }) + + return Result.ok(new GrantedAccess({ agent, proofs: [authorization] })) + } +} + +/** + * @param {string} password + */ +const deriveSigner = async (password) => { + const { digest } = await sha256.digest(new TextEncoder().encode(password)) + return await ed25519.Signer.derive(digest) +} + +export class Coupon { + /** + * @param {Model} model + */ + constructor(model) { + this.model = model + } + + get proofs() { + return this.model.proofs + } + + /** + * + * @param {API.Agent} agent + * @param {object} [options] + * @param {string} [options.password] + */ + redeem(agent, options = {}) { + return redeem(this, { ...options, agent }) + } + + archive() { + return archive(this) + } +} diff --git a/packages/w3up-client/test/access.test.js b/packages/w3up-client/test/access.test.js index d4f72b01e..9cdf83f91 100644 --- a/packages/w3up-client/test/access.test.js +++ b/packages/w3up-client/test/access.test.js @@ -24,7 +24,6 @@ export const testAccess = { assert.ok(request.expiration.getTime() >= Date.now()) const access = Result.try(await request.claim()) - assert.deepEqual(access.authority, client.did()) assert.ok(access.proofs.length > 0) const proofs = client.proofs() diff --git a/packages/w3up-client/test/coupon.test.js b/packages/w3up-client/test/coupon.test.js new file mode 100644 index 000000000..5e5d1bea8 --- /dev/null +++ b/packages/w3up-client/test/coupon.test.js @@ -0,0 +1,90 @@ +import * as Test from './test.js' +import * as Result from '../src/result.js' + +/** + * @type {Test.Suite} + */ +export const testCoupon = { + 'account.coupon': async ( + assert, + { client, mail, connect, grantAccess, plansStorage } + ) => { + // First we login to the workshop account + const login = client.login('workshop@web3.storage') + const message = await mail.take() + await grantAccess(message) + const account = await login + + // Then we setup a billing for this account + await plansStorage.set(account.did(), 'did:web:test.web3.storage') + + // Then we use the account to issue a coupon for the workshop + const coupon = await client.coupon.issue({ + capabilities: [ + { + with: account.did(), + can: 'provider/add', + }, + ], + lifetimeInSeconds: 60 * 60 * 24, + }) + + // We encode coupon and share it with the participants + const archive = Result.unwrap(await coupon.archive()) + + // alice join the workshop and redeem the coupon + const alice = await connect() + const access = await alice.coupon.redeem(archive) + + // creates a space and provision it with redeemed coupon + const space = await alice.createSpace('home') + const result = await space.provision(access) + await space.save() + + assert.ok(result.ok) + + const info = await alice.capability.space.info(space.did()) + assert.deepEqual(info.did, space.did()) + assert.deepEqual(info.providers, ['did:web:test.web3.storage']) + }, + + 'coupon with password': async ( + assert, + { client, mail, connect, grantAccess, plansStorage } + ) => { + const coupon = await client.coupon.issue({ + capabilities: [ + { + with: client.did(), + can: 'store/list', + }, + ], + password: 'secret', + }) + + const archive = Result.unwrap(await coupon.archive()) + + const wrongPassword = await client.coupon + .redeem(archive, { password: 'wrong' }) + .catch((e) => e) + + assert.match(String(wrongPassword), /password is invalid/) + + const requiresPassword = await client.coupon.redeem(archive).catch((e) => e) + + assert.match(String(requiresPassword), /requires a password/) + + const redeem = await coupon.redeem(client.agent, { password: 'secret' }) + assert.ok(redeem.ok) + }, + + 'corrupt coupon': async (assert, { client, mail, connect, grantAccess }) => { + const fail = await client.coupon + .redeem(new Uint8Array(32).fill(1)) + .catch((e) => e) + + assert.match(fail.message, /Invalid CAR header format/) + }, +} + +Test.test({ Access: testCoupon })