Skip to content

Commit 50eeeb5

Browse files
Travis Vachonvasco-santos
Travis Vachon
andauthored
feat: add "plan/create-admin-session" capability (#1411)
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 <[email protected]>
1 parent afbbde3 commit 50eeeb5

File tree

21 files changed

+8758
-6497
lines changed

21 files changed

+8758
-6497
lines changed

package.json

-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
"depcheck": "^1.4.3",
4242
"typedoc-plugin-missing-exports": "^2.1.0"
4343
},
44-
"packageManager": "[email protected]",
4544
"pnpm": {
4645
"peerDependencyRules": {
4746
"ignoreMissing": [

packages/access-client/src/types.ts

+8
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ import type {
6161
PlanSet,
6262
PlanSetSuccess,
6363
PlanSetFailure,
64+
PlanCreateAdminSession,
65+
PlanCreateAdminSessionSuccess,
66+
PlanCreateAdminSessionFailure,
6467
} from '@web3-storage/capabilities/types'
6568
import type { SetRequired } from 'type-fest'
6669
import { Driver } from './drivers/types.js'
@@ -144,6 +147,11 @@ export interface Service {
144147
plan: {
145148
get: ServiceMethod<PlanGet, PlanGetSuccess, PlanGetFailure>
146149
set: ServiceMethod<PlanSet, PlanSetSuccess, PlanSetFailure>
150+
'create-admin-session': ServiceMethod<
151+
PlanCreateAdminSession,
152+
PlanCreateAdminSessionSuccess,
153+
PlanCreateAdminSessionFailure
154+
>
147155
}
148156
}
149157

packages/capabilities/readme.md

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import * as Top from '@web3-storage/capabilities/top'
2929
import * as Types from '@web3-storage/capabilities/types'
3030
import * as Upload from '@web3-storage/capabilities/upload'
3131
import * as Utils from '@web3-storage/capabilities/utils'
32+
import * as Plan from '@web3-storage/capabilities/plan'
3233
import * as Filecoin from '@web3-storage/capabilities/filecoin'
3334
import * as Aggregator from '@web3-storage/capabilities/filecoin/aggregator'
3435
import * as DealTracker from '@web3-storage/capabilities/filecoin/deal-tracker'

packages/capabilities/src/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ export const abilitiesAsStrings = [
9393
Admin.upload.inspect.can,
9494
Admin.store.inspect.can,
9595
Plan.get.can,
96+
Plan.set.can,
97+
Plan.createAdminSession.can,
9698
Usage.usage.can,
9799
Usage.report.can,
98100
Blob.blob.can,

packages/capabilities/src/plan.js

+22-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DID, capability, ok, struct } from '@ucanto/validator'
1+
import { DID, Schema, capability, ok, struct } from '@ucanto/validator'
22
import { AccountDID, equal, equalWith, and } from './utils.js'
33

44
/**
@@ -30,3 +30,24 @@ export const set = capability({
3030
)
3131
},
3232
})
33+
34+
/**
35+
* Capability can be invoked by an account to generate a billing admin session.
36+
*
37+
* May not be possible with all billing providers - this is designed with
38+
* https://docs.stripe.com/api/customer_portal/sessions/create in mind.
39+
*/
40+
export const createAdminSession = capability({
41+
can: 'plan/create-admin-session',
42+
with: AccountDID,
43+
nb: struct({
44+
returnURL: Schema.string(),
45+
}),
46+
derives: (child, parent) => {
47+
return (
48+
and(equalWith(child, parent)) ||
49+
and(equal(child.nb.returnURL, parent.nb.returnURL, 'returnURL')) ||
50+
ok({})
51+
)
52+
},
53+
})

packages/capabilities/src/types.ts

+17
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,21 @@ export type PlanSetFailure =
834834
| PlanUpdateError
835835
| UnexpectedError
836836

837+
export type PlanCreateAdminSession = InferInvokedCapability<
838+
typeof PlanCaps.createAdminSession
839+
>
840+
841+
export interface PlanCreateAdminSessionSuccess {
842+
url: string
843+
}
844+
export interface AdminSessionNotSupported extends Ucanto.Failure {
845+
name: 'AdminSessionNotSupported'
846+
}
847+
export type PlanCreateAdminSessionFailure =
848+
| AdminSessionNotSupported
849+
| CustomerNotFound
850+
| UnexpectedError
851+
837852
// Top
838853
export type Top = InferInvokedCapability<typeof top>
839854

@@ -879,6 +894,8 @@ export type ServiceAbilityArray = [
879894
AdminUploadInspect['can'],
880895
AdminStoreInspect['can'],
881896
PlanGet['can'],
897+
PlanSet['can'],
898+
PlanCreateAdminSession['can'],
882899
Usage['can'],
883900
UsageReport['can'],
884901
Blob['can'],

packages/capabilities/test/capabilities/plan.test.js

+169
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,172 @@ describe('plan/set', function () {
260260
assert.equal(result.error?.message.includes('not authorized'), true)
261261
})
262262
})
263+
264+
describe('plan/create-admin-session', function () {
265+
const agent = alice
266+
const account = 'did:mailto:mallory.com:mallory'
267+
it('can invoke as an account', async function () {
268+
const auth = Plan.createAdminSession.invoke({
269+
issuer: agent,
270+
audience: service,
271+
with: account,
272+
nb: {
273+
returnURL: 'http://example.com/return',
274+
},
275+
proofs: await createAuthorization({ agent, service, account }),
276+
})
277+
const result = await access(await auth.delegate(), {
278+
capability: Plan.createAdminSession,
279+
principal: Verifier,
280+
authority: service,
281+
validateAuthorization,
282+
})
283+
if (result.error) {
284+
assert.fail(`error in self issue: ${result.error.message}`)
285+
} else {
286+
assert.deepEqual(result.ok.audience.did(), service.did())
287+
assert.equal(result.ok.capability.can, 'plan/create-admin-session')
288+
assert.deepEqual(result.ok.capability.with, account)
289+
}
290+
})
291+
292+
it('fails without account delegation', async function () {
293+
const agent = alice
294+
const auth = Plan.createAdminSession.invoke({
295+
issuer: agent,
296+
audience: service,
297+
with: account,
298+
nb: {
299+
returnURL: 'http://example.com/return',
300+
},
301+
})
302+
303+
const result = await access(await auth.delegate(), {
304+
capability: Plan.createAdminSession,
305+
principal: Verifier,
306+
authority: service,
307+
validateAuthorization,
308+
})
309+
310+
assert.equal(result.error?.message.includes('not authorized'), true)
311+
})
312+
313+
it('fails when invoked by a different agent', async function () {
314+
const auth = Plan.createAdminSession.invoke({
315+
issuer: bob,
316+
audience: service,
317+
with: account,
318+
nb: {
319+
returnURL: 'http://example.com/return',
320+
},
321+
proofs: await createAuthorization({ agent, service, account }),
322+
})
323+
324+
const result = await access(await auth.delegate(), {
325+
capability: Plan.createAdminSession,
326+
principal: Verifier,
327+
authority: service,
328+
validateAuthorization,
329+
})
330+
assert.equal(result.error?.message.includes('not authorized'), true)
331+
})
332+
333+
it('can delegate plan/create-admin-session', async function () {
334+
const invocation = Plan.createAdminSession.invoke({
335+
issuer: bob,
336+
audience: service,
337+
with: account,
338+
nb: {
339+
returnURL: 'http://example.com/return',
340+
},
341+
proofs: [
342+
await Plan.createAdminSession.delegate({
343+
issuer: agent,
344+
audience: bob,
345+
with: account,
346+
nb: {
347+
returnURL: 'http://example.com/return',
348+
},
349+
proofs: await createAuthorization({ agent, service, account }),
350+
}),
351+
],
352+
})
353+
const result = await access(await invocation.delegate(), {
354+
capability: Plan.createAdminSession,
355+
principal: Verifier,
356+
authority: service,
357+
validateAuthorization,
358+
})
359+
if (result.error) {
360+
assert.fail(`error in self issue: ${result.error.message}`)
361+
} else {
362+
assert.deepEqual(result.ok.audience.did(), service.did())
363+
assert.equal(result.ok.capability.can, 'plan/create-admin-session')
364+
assert.deepEqual(result.ok.capability.with, account)
365+
}
366+
})
367+
368+
it('can invoke plan/create-admin-session with the return URL that its delegation specifies', async function () {
369+
const invocation = Plan.createAdminSession.invoke({
370+
issuer: bob,
371+
audience: service,
372+
with: account,
373+
nb: {
374+
returnURL: 'http://example.com/return',
375+
},
376+
proofs: [
377+
await Plan.createAdminSession.delegate({
378+
issuer: agent,
379+
audience: bob,
380+
with: account,
381+
nb: {
382+
returnURL: 'http://example.com/return',
383+
},
384+
proofs: await createAuthorization({ agent, service, account }),
385+
}),
386+
],
387+
})
388+
const result = await access(await invocation.delegate(), {
389+
capability: Plan.createAdminSession,
390+
principal: Verifier,
391+
authority: service,
392+
validateAuthorization,
393+
})
394+
if (result.error) {
395+
assert.fail(`error in self issue: ${result.error.message}`)
396+
} else {
397+
assert.deepEqual(result.ok.audience.did(), service.did())
398+
assert.equal(result.ok.capability.can, 'plan/create-admin-session')
399+
assert.deepEqual(result.ok.capability.with, account)
400+
}
401+
})
402+
403+
it('cannot invoke plan/create-admin-session with a different product than its delegation specifies', async function () {
404+
const invocation = Plan.createAdminSession.invoke({
405+
issuer: bob,
406+
audience: service,
407+
with: account,
408+
nb: {
409+
returnURL: 'http://example.com/bad-return',
410+
},
411+
proofs: [
412+
await Plan.createAdminSession.delegate({
413+
issuer: agent,
414+
audience: bob,
415+
with: account,
416+
nb: {
417+
returnURL: 'http://example.com/return',
418+
},
419+
proofs: await createAuthorization({ agent, service, account }),
420+
}),
421+
],
422+
})
423+
const result = await access(await invocation.delegate(), {
424+
capability: Plan.createAdminSession,
425+
principal: Verifier,
426+
authority: service,
427+
validateAuthorization,
428+
})
429+
assert.equal(result.error?.message.includes('not authorized'), true)
430+
})
431+
})

packages/upload-api/src/plan.js

+8-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as Types from './types.js'
22
import * as Get from './plan/get.js'
33
import * as Set from './plan/set.js'
4+
import * as CreateAdminSession from './plan/create-admin-session.js'
45

56
import { Failure } from '@ucanto/server'
67

@@ -49,7 +50,10 @@ export class CustomerExists extends Failure {
4950
/**
5051
* @param {Types.PlanServiceContext} context
5152
*/
52-
export const createService = (context) => ({
53-
get: Get.provide(context),
54-
set: Set.provide(context),
55-
})
53+
export const createService = (context) => {
54+
return {
55+
get: Get.provide(context),
56+
set: Set.provide(context),
57+
'create-admin-session': CreateAdminSession.provide(context),
58+
}
59+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import * as API from '../types.js'
2+
import * as Provider from '@ucanto/server'
3+
import { Plan } from '@web3-storage/capabilities'
4+
5+
/**
6+
* @param {API.PlanServiceContext} context
7+
*/
8+
export const provide = (context) =>
9+
Provider.provide(Plan.createAdminSession, (input) =>
10+
createAdminSession(input, context)
11+
)
12+
13+
/**
14+
* @param {API.Input<Plan.createAdminSession>} input
15+
* @param {API.PlanServiceContext} context
16+
* @returns {Promise<API.Result<API.PlanCreateAdminSessionSuccess, API.PlanCreateAdminSessionFailure>>}
17+
*/
18+
const createAdminSession = async ({ capability }, context) =>
19+
context.plansStorage.createAdminSession(
20+
capability.with,
21+
capability.nb.returnURL
22+
)

packages/upload-api/src/types.ts

+8
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ import {
155155
PlanSetSuccess,
156156
PlanSetFailure,
157157
PlanSet,
158+
PlanCreateAdminSession,
159+
PlanCreateAdminSessionSuccess,
160+
PlanCreateAdminSessionFailure,
158161
IndexAdd,
159162
IndexAddSuccess,
160163
IndexAddFailure,
@@ -318,6 +321,11 @@ export interface Service extends StorefrontService, W3sService {
318321
plan: {
319322
get: ServiceMethod<PlanGet, PlanGetSuccess, PlanGetFailure>
320323
set: ServiceMethod<PlanSet, PlanSetSuccess, PlanSetFailure>
324+
'create-admin-session': ServiceMethod<
325+
PlanCreateAdminSession,
326+
PlanCreateAdminSessionSuccess,
327+
PlanCreateAdminSessionFailure
328+
>
321329
}
322330
usage: {
323331
report: ServiceMethod<UsageReport, UsageReportSuccess, UsageReportFailure>

packages/upload-api/src/types/plans.ts

+17
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
PlanGetSuccess,
77
PlanSetFailure,
88
PlanSetSuccess,
9+
PlanCreateAdminSessionFailure,
10+
PlanCreateAdminSessionSuccess,
911
UnexpectedError,
1012
} from '../types.js'
1113

@@ -57,4 +59,19 @@ export interface PlansStorage {
5759
account: AccountDID,
5860
plan: PlanID
5961
) => Promise<Ucanto.Result<PlanSetSuccess, PlanSetFailure>>
62+
63+
/**
64+
* Set a customer's billing email. Update our systems and any third party billing systems.
65+
*
66+
* May not be possible with all billing providers - this is designed with
67+
* https://docs.stripe.com/api/customer_portal/sessions/create in mind.
68+
*
69+
* @param account account DID
70+
*/
71+
createAdminSession: (
72+
account: AccountDID,
73+
returnURL: string
74+
) => Promise<
75+
Ucanto.Result<PlanCreateAdminSessionSuccess, PlanCreateAdminSessionFailure>
76+
>
6077
}

packages/upload-api/test/storage/plans-storage.js

+14
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,18 @@ export class PlansStorage {
6363
this.plans[account].updatedAt = new Date().toISOString()
6464
return { ok: {} }
6565
}
66+
67+
/**
68+
* @param {Types.AccountDID} account
69+
* @returns {Promise<import('@ucanto/interface').Result<import('../types.js').PlanCreateAdminSessionSuccess, import('../types.js').PlanCreateAdminSessionFailure>>}
70+
*/
71+
async createAdminSession(account) {
72+
if (this.plans[account]) {
73+
return { ok: { url: 'https://example.com/admin-session' } }
74+
} else {
75+
return {
76+
error: { name: 'CustomerNotFound', message: `${account} not found` },
77+
}
78+
}
79+
}
6680
}

0 commit comments

Comments
 (0)