Skip to content

Commit

Permalink
feat: add "plan/create-admin-session" capability (storacha#1411)
Browse files Browse the repository at this point in the history
Our billing system has an external admin customer portal system and
their API lets us generate sessions for it on demand - add a capability
that will let our customers delegate this ability the email address that
is responsible for paying the bills.

See storacha/console#98 for an example of
this in action.

---------

Co-authored-by: Vasco Santos <santos.vasco10@gmail.com>
  • Loading branch information
travis and vasco-santos authored May 13, 2024
1 parent afbbde3 commit 50eeeb5
Show file tree
Hide file tree
Showing 21 changed files with 8,758 additions and 6,497 deletions.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
"depcheck": "^1.4.3",
"typedoc-plugin-missing-exports": "^2.1.0"
},
"packageManager": "pnpm@7.24.3",
"pnpm": {
"peerDependencyRules": {
"ignoreMissing": [
Expand Down
8 changes: 8 additions & 0 deletions packages/access-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ import type {
PlanSet,
PlanSetSuccess,
PlanSetFailure,
PlanCreateAdminSession,
PlanCreateAdminSessionSuccess,
PlanCreateAdminSessionFailure,
} from '@web3-storage/capabilities/types'
import type { SetRequired } from 'type-fest'
import { Driver } from './drivers/types.js'
Expand Down Expand Up @@ -144,6 +147,11 @@ export interface Service {
plan: {
get: ServiceMethod<PlanGet, PlanGetSuccess, PlanGetFailure>
set: ServiceMethod<PlanSet, PlanSetSuccess, PlanSetFailure>
'create-admin-session': ServiceMethod<
PlanCreateAdminSession,
PlanCreateAdminSessionSuccess,
PlanCreateAdminSessionFailure
>
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/capabilities/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ 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 Plan from '@web3-storage/capabilities/plan'
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
2 changes: 2 additions & 0 deletions packages/capabilities/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ export const abilitiesAsStrings = [
Admin.upload.inspect.can,
Admin.store.inspect.can,
Plan.get.can,
Plan.set.can,
Plan.createAdminSession.can,
Usage.usage.can,
Usage.report.can,
Blob.blob.can,
Expand Down
23 changes: 22 additions & 1 deletion packages/capabilities/src/plan.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DID, capability, ok, struct } from '@ucanto/validator'
import { DID, Schema, capability, ok, struct } from '@ucanto/validator'
import { AccountDID, equal, equalWith, and } from './utils.js'

/**
Expand Down Expand Up @@ -30,3 +30,24 @@ export const set = capability({
)
},
})

/**
* Capability can be invoked by an account to generate a billing admin session.
*
* May not be possible with all billing providers - this is designed with
* https://docs.stripe.com/api/customer_portal/sessions/create in mind.
*/
export const createAdminSession = capability({
can: 'plan/create-admin-session',
with: AccountDID,
nb: struct({
returnURL: Schema.string(),
}),
derives: (child, parent) => {
return (
and(equalWith(child, parent)) ||
and(equal(child.nb.returnURL, parent.nb.returnURL, 'returnURL')) ||
ok({})
)
},
})
17 changes: 17 additions & 0 deletions packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,21 @@ export type PlanSetFailure =
| PlanUpdateError
| UnexpectedError

export type PlanCreateAdminSession = InferInvokedCapability<
typeof PlanCaps.createAdminSession
>

export interface PlanCreateAdminSessionSuccess {
url: string
}
export interface AdminSessionNotSupported extends Ucanto.Failure {
name: 'AdminSessionNotSupported'
}
export type PlanCreateAdminSessionFailure =
| AdminSessionNotSupported
| CustomerNotFound
| UnexpectedError

// Top
export type Top = InferInvokedCapability<typeof top>

Expand Down Expand Up @@ -879,6 +894,8 @@ export type ServiceAbilityArray = [
AdminUploadInspect['can'],
AdminStoreInspect['can'],
PlanGet['can'],
PlanSet['can'],
PlanCreateAdminSession['can'],
Usage['can'],
UsageReport['can'],
Blob['can'],
Expand Down
169 changes: 169 additions & 0 deletions packages/capabilities/test/capabilities/plan.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,3 +260,172 @@ describe('plan/set', function () {
assert.equal(result.error?.message.includes('not authorized'), true)
})
})

describe('plan/create-admin-session', function () {
const agent = alice
const account = 'did:mailto:mallory.com:mallory'
it('can invoke as an account', async function () {
const auth = Plan.createAdminSession.invoke({
issuer: agent,
audience: service,
with: account,
nb: {
returnURL: 'http://example.com/return',
},
proofs: await createAuthorization({ agent, service, account }),
})
const result = await access(await auth.delegate(), {
capability: Plan.createAdminSession,
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/create-admin-session')
assert.deepEqual(result.ok.capability.with, account)
}
})

it('fails without account delegation', async function () {
const agent = alice
const auth = Plan.createAdminSession.invoke({
issuer: agent,
audience: service,
with: account,
nb: {
returnURL: 'http://example.com/return',
},
})

const result = await access(await auth.delegate(), {
capability: Plan.createAdminSession,
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.createAdminSession.invoke({
issuer: bob,
audience: service,
with: account,
nb: {
returnURL: 'http://example.com/return',
},
proofs: await createAuthorization({ agent, service, account }),
})

const result = await access(await auth.delegate(), {
capability: Plan.createAdminSession,
principal: Verifier,
authority: service,
validateAuthorization,
})
assert.equal(result.error?.message.includes('not authorized'), true)
})

it('can delegate plan/create-admin-session', async function () {
const invocation = Plan.createAdminSession.invoke({
issuer: bob,
audience: service,
with: account,
nb: {
returnURL: 'http://example.com/return',
},
proofs: [
await Plan.createAdminSession.delegate({
issuer: agent,
audience: bob,
with: account,
nb: {
returnURL: 'http://example.com/return',
},
proofs: await createAuthorization({ agent, service, account }),
}),
],
})
const result = await access(await invocation.delegate(), {
capability: Plan.createAdminSession,
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/create-admin-session')
assert.deepEqual(result.ok.capability.with, account)
}
})

it('can invoke plan/create-admin-session with the return URL that its delegation specifies', async function () {
const invocation = Plan.createAdminSession.invoke({
issuer: bob,
audience: service,
with: account,
nb: {
returnURL: 'http://example.com/return',
},
proofs: [
await Plan.createAdminSession.delegate({
issuer: agent,
audience: bob,
with: account,
nb: {
returnURL: 'http://example.com/return',
},
proofs: await createAuthorization({ agent, service, account }),
}),
],
})
const result = await access(await invocation.delegate(), {
capability: Plan.createAdminSession,
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/create-admin-session')
assert.deepEqual(result.ok.capability.with, account)
}
})

it('cannot invoke plan/create-admin-session with a different product than its delegation specifies', async function () {
const invocation = Plan.createAdminSession.invoke({
issuer: bob,
audience: service,
with: account,
nb: {
returnURL: 'http://example.com/bad-return',
},
proofs: [
await Plan.createAdminSession.delegate({
issuer: agent,
audience: bob,
with: account,
nb: {
returnURL: 'http://example.com/return',
},
proofs: await createAuthorization({ agent, service, account }),
}),
],
})
const result = await access(await invocation.delegate(), {
capability: Plan.createAdminSession,
principal: Verifier,
authority: service,
validateAuthorization,
})
assert.equal(result.error?.message.includes('not authorized'), true)
})
})
12 changes: 8 additions & 4 deletions packages/upload-api/src/plan.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as Types from './types.js'
import * as Get from './plan/get.js'
import * as Set from './plan/set.js'
import * as CreateAdminSession from './plan/create-admin-session.js'

import { Failure } from '@ucanto/server'

Expand Down Expand Up @@ -49,7 +50,10 @@ export class CustomerExists extends Failure {
/**
* @param {Types.PlanServiceContext} context
*/
export const createService = (context) => ({
get: Get.provide(context),
set: Set.provide(context),
})
export const createService = (context) => {
return {
get: Get.provide(context),
set: Set.provide(context),
'create-admin-session': CreateAdminSession.provide(context),
}
}
22 changes: 22 additions & 0 deletions packages/upload-api/src/plan/create-admin-session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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.createAdminSession, (input) =>
createAdminSession(input, context)
)

/**
* @param {API.Input<Plan.createAdminSession>} input
* @param {API.PlanServiceContext} context
* @returns {Promise<API.Result<API.PlanCreateAdminSessionSuccess, API.PlanCreateAdminSessionFailure>>}
*/
const createAdminSession = async ({ capability }, context) =>
context.plansStorage.createAdminSession(
capability.with,
capability.nb.returnURL
)
8 changes: 8 additions & 0 deletions packages/upload-api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ import {
PlanSetSuccess,
PlanSetFailure,
PlanSet,
PlanCreateAdminSession,
PlanCreateAdminSessionSuccess,
PlanCreateAdminSessionFailure,
IndexAdd,
IndexAddSuccess,
IndexAddFailure,
Expand Down Expand Up @@ -318,6 +321,11 @@ export interface Service extends StorefrontService, W3sService {
plan: {
get: ServiceMethod<PlanGet, PlanGetSuccess, PlanGetFailure>
set: ServiceMethod<PlanSet, PlanSetSuccess, PlanSetFailure>
'create-admin-session': ServiceMethod<
PlanCreateAdminSession,
PlanCreateAdminSessionSuccess,
PlanCreateAdminSessionFailure
>
}
usage: {
report: ServiceMethod<UsageReport, UsageReportSuccess, UsageReportFailure>
Expand Down
17 changes: 17 additions & 0 deletions packages/upload-api/src/types/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
PlanGetSuccess,
PlanSetFailure,
PlanSetSuccess,
PlanCreateAdminSessionFailure,
PlanCreateAdminSessionSuccess,
UnexpectedError,
} from '../types.js'

Expand Down Expand Up @@ -57,4 +59,19 @@ export interface PlansStorage {
account: AccountDID,
plan: PlanID
) => Promise<Ucanto.Result<PlanSetSuccess, PlanSetFailure>>

/**
* Set a customer's billing email. Update our systems and any third party billing systems.
*
* May not be possible with all billing providers - this is designed with
* https://docs.stripe.com/api/customer_portal/sessions/create in mind.
*
* @param account account DID
*/
createAdminSession: (
account: AccountDID,
returnURL: string
) => Promise<
Ucanto.Result<PlanCreateAdminSessionSuccess, PlanCreateAdminSessionFailure>
>
}
14 changes: 14 additions & 0 deletions packages/upload-api/test/storage/plans-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,18 @@ export class PlansStorage {
this.plans[account].updatedAt = new Date().toISOString()
return { ok: {} }
}

/**
* @param {Types.AccountDID} account
* @returns {Promise<import('@ucanto/interface').Result<import('../types.js').PlanCreateAdminSessionSuccess, import('../types.js').PlanCreateAdminSessionFailure>>}
*/
async createAdminSession(account) {
if (this.plans[account]) {
return { ok: { url: 'https://example.com/admin-session' } }
} else {
return {
error: { name: 'CustomerNotFound', message: `${account} not found` },
}
}
}
}
Loading

0 comments on commit 50eeeb5

Please sign in to comment.