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: add "plan/create-admin-session" capability #1411

Merged
merged 13 commits into from
May 13, 2024
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
Loading