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!: coupon #1136

Merged
merged 2 commits into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading