Skip to content

Commit

Permalink
feat!: coupon (storacha#1136)
Browse files Browse the repository at this point in the history
  • Loading branch information
Gozala authored Nov 15, 2023
1 parent a6590fe commit 1b94f2d
Show file tree
Hide file tree
Showing 9 changed files with 299 additions and 24 deletions.
24 changes: 9 additions & 15 deletions packages/access-client/src/access.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<API.Delegation>} */ (
delegations.flatMap((proof) => bytesToDelegations(proof))
)

return { ok: new GrantedAccess({ agent, proofs }) }
}
}

Expand Down Expand Up @@ -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<API.Delegation>} */ (result.ok),
}),
}
}
Expand Down Expand Up @@ -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<API.Delegation>} proofs - Delegations that grant access.
*
* @param {GrantedAccessModel} model
*/
Expand All @@ -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
Expand Down
5 changes: 2 additions & 3 deletions packages/access-client/src/agent-use-cases.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 47 additions & 4 deletions packages/access-client/src/space.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 })
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 0 additions & 1 deletion packages/capabilities/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions packages/w3up-client/src/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
2 changes: 2 additions & 0 deletions packages/w3up-client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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() {
Expand Down
148 changes: 148 additions & 0 deletions packages/w3up-client/src/coupon.js
Original file line number Diff line number Diff line change
@@ -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<CouponOptions, 'issuer'>} 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<API.Result<Coupon, Error>>}
*/
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<import('@ucanto/interface').DelegationOptions<API.Capabilities>, '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<API.Result<GrantedAccess, Error>>}
*/
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)
}
}
1 change: 0 additions & 1 deletion packages/w3up-client/test/access.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading

0 comments on commit 1b94f2d

Please sign in to comment.