Skip to content

Commit

Permalink
feat: content serve authorization (storacha#1590) + set default gatew…
Browse files Browse the repository at this point in the history
…ay (#99)

To enable a gateway to serve content from a specific space, we must
ensure that the space owner delegates the `space/content/serve/*`
capability to the Gateway. This delegation allows the Gateway to serve
content and log egress events appropriately.

I created a new function `authorizeContentServe` for this implementation
and included it in the `createSpace` flow. This is a breaking change
because now the user is forced to provide the DIDs of the Content Serve
services, and the connection, or skip the authorization flow.

Additionally, with the `authorizeContentServe` function, we can
implement a feature in the Console App that enables users to explicitly
authorize the Freeway Gateway to serve content from existing/legacy
spaces.

- **New Functionality:** 
- Added a new function, `authorizeContentServe`, in the `w3up-client`
module to facilitate the delegation process. Integrated it with the
`createdSpace` flow.
- It also sets the Storacha Gateway as the default content server
service in case the user doesn't provide any in the `createSpace` call,
and doesn't use the `skipGatewayAuthorization=true` flag.
- **Testing:** Introduced test cases to verify the authorization of
specified gateways.
- **Fixes:** Resolved issues with previously broken test cases (Egress
Record).

### Related Issues
- storacha/project-tracking#158
- storacha/project-tracking#160
- storacha/project-tracking#207
- storacha#1604
- Resolves storacha/project-tracking#196
  • Loading branch information
fforbeck authored Dec 19, 2024
1 parent 07f27a2 commit 6cbb202
Show file tree
Hide file tree
Showing 28 changed files with 3,235 additions and 1,463 deletions.
24 changes: 14 additions & 10 deletions packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,6 @@ export type UsageReport = InferInvokedCapability<typeof UsageCaps.report>
export type UsageReportSuccess = Record<ProviderDID, UsageData>
export type UsageReportFailure = Ucanto.Failure

export type EgressRecord = InferInvokedCapability<typeof SpaceCaps.egressRecord>
export type EgressRecordSuccess = {
space: SpaceDID
resource: UnknownLink
bytes: number
servedAt: ISO8601Date
cause: UnknownLink
}
export type EgressRecordFailure = ConsumerNotFound | Ucanto.Failure

export interface UsageData {
/** Provider the report concerns, e.g. `did:web:storacha.network` */
provider: ProviderDID
Expand Down Expand Up @@ -285,6 +275,18 @@ export type RateLimitListFailure = Ucanto.Failure
// Space
export type Space = InferInvokedCapability<typeof SpaceCaps.space>
export type SpaceInfo = InferInvokedCapability<typeof SpaceCaps.info>
export type SpaceContentServe = InferInvokedCapability<
typeof SpaceCaps.contentServe
>
export type EgressRecord = InferInvokedCapability<typeof SpaceCaps.egressRecord>
export type EgressRecordSuccess = {
space: SpaceDID
resource: UnknownLink
bytes: number
servedAt: ISO8601Date
cause: UnknownLink
}
export type EgressRecordFailure = ConsumerNotFound | Ucanto.Failure

// filecoin
export interface DealMetadata {
Expand Down Expand Up @@ -901,6 +903,8 @@ export type ServiceAbilityArray = [
ProviderAdd['can'],
Space['can'],
SpaceInfo['can'],
SpaceContentServe['can'],
EgressRecord['can'],
Upload['can'],
UploadAdd['can'],
UploadGet['can'],
Expand Down
31 changes: 30 additions & 1 deletion packages/cli/bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,36 @@ cli
.option('-c, --customer <email>', 'Billing account email')
.option('-na, --no-account', 'Skip account setup')
.option('-a, --account <email>', 'Managing account email')
.action(Space.create)
.option(
'-ag, --authorize-gateway-services <json>',
'Authorize Gateways to serve the content uploaded to this space, e.g: \'[{"id":"did:key:z6Mki...","serviceEndpoint":"https://gateway.example.com"}]\''
)
.option('-nga, --no-gateway-authorization', 'Skip Gateway Authorization')
.action((name, options) => {
let authorizeGatewayServices = []
if (options['authorize-gateway-services']) {
try {
authorizeGatewayServices = JSON.parse(
options['authorize-gateway-services']
)
} catch (err) {
console.error('Invalid JSON format for --authorize-gateway-services')
process.exit(1)
}
}

const parsedOptions = {
...options,
// if defined it means we want to skip gateway authorization, so the client will not validate the gateway services
skipGatewayAuthorization:
options['gateway-authorization'] === false ||
options['gateway-authorization'] === undefined,
// default to empty array if not set, so the client will validate the gateway services
authorizeGatewayServices: authorizeGatewayServices || [],
}

return Space.create(name, parsedOptions)
})

cli
.command('space provision [name]')
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,9 @@ export async function remove(rootCid, opts) {
*/
export async function createSpace(name) {
const client = await getClient()
const space = await client.createSpace(name)
const space = await client.createSpace(name, {
skipGatewayAuthorization: true,
})
await client.setCurrentSpace(space.did())
console.log(space.did())
}
Expand Down
28 changes: 27 additions & 1 deletion packages/cli/space.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import * as W3Space from '@storacha/client/space'
import * as W3Account from '@storacha/client/account'
import * as UcantoClient from '@ucanto/client'
import { HTTP } from '@ucanto/transport'
import * as CAR from '@ucanto/transport/car'
import { getClient } from './lib.js'
import process from 'node:process'
import * as DIDMailto from '@storacha/did-mailto'
Expand All @@ -17,6 +20,8 @@ import * as Result from '@storacha/client/result'
* @property {false} [caution]
* @property {DIDMailto.EmailAddress|false} [customer]
* @property {string|false} [account]
* @property {Array<{id: import('@ucanto/interface').DID, serviceEndpoint: string}>} [authorizeGatewayServices] - The DID Key or DID Web and URL of the Gateway to authorize to serve content from the created space.
* @property {boolean} [skipGatewayAuthorization] - Whether to skip the Gateway authorization. It means that the content of the space will not be served by any Gateway.
*
* @param {string|undefined} name
* @param {CreateOptions} options
Expand All @@ -25,7 +30,28 @@ export const create = async (name, options) => {
const client = await getClient()
const spaces = client.spaces()

const space = await client.createSpace(await chooseName(name ?? '', spaces))
let space
if (options.skipGatewayAuthorization === true) {
space = await client.createSpace(await chooseName(name ?? '', spaces), {
skipGatewayAuthorization: true,
})
} else {
const gateways = options.authorizeGatewayServices ?? []
const connections = gateways.map(({ id, serviceEndpoint }) => {
/** @type {UcantoClient.ConnectionView<import('@storacha/client/types').ContentServeService>} */
const connection = UcantoClient.connect({
id: {
did: () => id,
},
codec: CAR.outbound,
channel: HTTP.open({ url: new URL(serviceEndpoint) }),
})
return connection
})
space = await client.createSpace(await chooseName(name ?? '', spaces), {
authorizeGatewayServices: connections,
})
}

// Unless use opted-out from paper key recovery, we go through the flow
if (options.recovery !== false) {
Expand Down
91 changes: 82 additions & 9 deletions packages/cli/test/bin.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export const testAccount = {
export const testSpace = {
'storacha space create': test(async (assert, context) => {
const command = storacha
.args(['space', 'create'])
.args(['space', 'create', '--no-gateway-authorization'])
.env(context.env.alice)
.fork()

Expand All @@ -115,7 +115,7 @@ export const testSpace = {

'storacha space create home': test(async (assert, context) => {
const create = storacha
.args(['space', 'create', 'home'])
.args(['space', 'create', 'home', '--no-gateway-authorization'])
.env(context.env.alice)
.fork()

Expand All @@ -136,7 +136,13 @@ export const testSpace = {

'storacha space create home --no-caution': test(async (assert, context) => {
const create = storacha
.args(['space', 'create', 'home', '--no-caution'])
.args([
'space',
'create',
'home',
'--no-caution',
'--no-gateway-authorization',
])
.env(context.env.alice)
.fork()

Expand All @@ -160,7 +166,13 @@ export const testSpace = {
'storacha space create my-space --no-recovery': test(
async (assert, context) => {
const create = storacha
.args(['space', 'create', 'home', '--no-recovery'])
.args([
'space',
'create',
'home',
'--no-recovery',
'--no-gateway-authorization',
])
.env(context.env.alice)
.fork()

Expand All @@ -179,7 +191,13 @@ export const testSpace = {
await selectPlan(context)

const create = storacha
.args(['space', 'create', 'home', '--no-recovery'])
.args([
'space',
'create',
'home',
'--no-recovery',
'--no-gateway-authorization',
])
.env(context.env.alice)
.fork()

Expand All @@ -197,7 +215,13 @@ export const testSpace = {
await login(context, { email: '[email protected]' })

const create = storacha
.args(['space', 'create', 'my-space', '--no-recovery'])
.args([
'space',
'create',
'my-space',
'--no-recovery',
'--no-gateway-authorization',
])
.env(context.env.alice)
.fork()

Expand Down Expand Up @@ -228,6 +252,7 @@ export const testSpace = {
'--customer',
'[email protected]',
'--no-account',
'--no-gateway-authorization',
])
.join()
.catch()
Expand All @@ -240,8 +265,6 @@ export const testSpace = {
'storacha space create home --no-recovery --customer [email protected] --no-account':
test(async (assert, context) => {
await login(context, { email: '[email protected]' })
await login(context, { email: '[email protected]' })

await selectPlan(context)

const create = await storacha
Expand All @@ -250,6 +273,7 @@ export const testSpace = {
'create',
'home',
'--no-recovery',
'--no-gateway-authorization',
'--customer',
'[email protected]',
'--no-account',
Expand Down Expand Up @@ -279,6 +303,7 @@ export const testSpace = {
'create',
'home',
'--no-recovery',
'--no-gateway-authorization',
'--customer',
email,
'--account',
Expand Down Expand Up @@ -312,13 +337,56 @@ export const testSpace = {

const { output, error } = await storacha
.env(context.env.alice)
.args(['space', 'create', 'home', '--no-recovery'])
.args([
'space',
'create',
'home',
'--no-recovery',
'--no-gateway-authorization',
])
.join()

assert.match(output, /billing account is set/i)
assert.match(error, /wait.*plan.*select/i)
}),

'storacha space create home --no-recovery --customer [email protected] --account [email protected] --authorize-gateway-services':
test(async (assert, context) => {
const email = '[email protected]'
await login(context, { email })
await selectPlan(context, { email })

const serverId = context.connection.id
const serverURL = context.serverURL

const { output } = await storacha
.args([
'space',
'create',
'home',
'--no-recovery',
'--customer',
email,
'--account',
email,
'--authorize-gateway-services',
`[{"id":"${serverId}","serviceEndpoint":"${serverURL}"}]`,
])
.env(context.env.alice)
.join()

assert.match(output, /account is authorized/i)

const result = await context.delegationsStorage.find({
audience: DIDMailto.fromEmail(email),
})

assert.ok(
result.ok?.find((d) => d.capabilities[0].can === '*'),
'account has been delegated access to the space'
)
}),

'storacha space add': test(async (assert, context) => {
const { env } = context

Expand Down Expand Up @@ -642,6 +710,7 @@ export const testStorachaUp = {
'home',
'--no-recovery',
'--no-account',
'--no-gateway-authorization',
'--customer',
email,
])
Expand Down Expand Up @@ -674,6 +743,7 @@ export const testStorachaUp = {
'home',
'--no-recovery',
'--no-account',
'--no-gateway-authorization',
'--customer',
email,
])
Expand Down Expand Up @@ -706,6 +776,7 @@ export const testStorachaUp = {
'home',
'--no-recovery',
'--no-account',
'--no-gateway-authorization',
'--customer',
email,
])
Expand Down Expand Up @@ -737,6 +808,7 @@ export const testStorachaUp = {
'home',
'--no-recovery',
'--no-account',
'--no-gateway-authorization',
'--customer',
email,
])
Expand Down Expand Up @@ -1371,6 +1443,7 @@ export const createSpace = async (
name,
'--no-recovery',
'--no-account',
'--no-gateway-authorization',
...(customer ? ['--customer', customer] : ['--no-customer']),
])
.env(env)
Expand Down
7 changes: 5 additions & 2 deletions packages/filecoin-api/src/aggregator/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,11 @@ export const handleAggregateInsertToPieceAcceptQueue = async (
// TODO: Batch per a maximum to queue
const results = await map(
pieces,
/** @returns {Promise<import('@ucanto/interface').Result<import('@ucanto/interface').Unit, RangeError|import('../types.js').QueueAddError>>} */
async (piece) => {
/**
* @param piece
* @returns {Promise<import('@ucanto/interface').Result<import('@ucanto/interface').Unit, RangeError|import('../types.js').QueueAddError>>}
*/
async piece => {
const inclusionProof = aggregateBuilder.resolveProof(piece.link)
if (inclusionProof.error) return inclusionProof

Expand Down
2 changes: 2 additions & 0 deletions packages/upload-api/src/access/claim.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ export const provide = (ctx) =>

/**
* Checks if the given Principal is an Account.
*
* @param {API.Principal} principal
* @returns {principal is API.Principal<API.DID<'mailto'>>}
*/
const isAccount = (principal) => principal.did().startsWith('did:mailto:')

/**
* Returns true when the delegation has a `ucan:*` capability.
*
* @param {API.Delegation} delegation
* @returns boolean
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/upload-api/src/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,8 @@ export const execute = async (agent, input) => {
* a receipt it will return receipt without running invocation.
*
* @template {Record<string, any>} S
* @param {Types.Invocation} invocation
* @param {Agent<S>} agent
* @param {Types.Invocation} invocation
*/
export const run = async (agent, invocation) => {
const cached = await agent.context.agentStore.receipts.get(invocation.link())
Expand Down
Loading

0 comments on commit 6cbb202

Please sign in to comment.