Skip to content

Commit

Permalink
feat: allow multiple providers (#595)
Browse files Browse the repository at this point in the history
Allows multiple providers to be used with the system and defines web3
and NFT providers in config.

---------

Co-authored-by: Irakli Gozalishvili <contact@gozala.io>
Co-authored-by: Benjamin Goering <171782+gobengo@users.noreply.github.com>
  • Loading branch information
3 people authored Mar 23, 2023
1 parent 97b9a1b commit 96c5a2e
Show file tree
Hide file tree
Showing 15 changed files with 586 additions and 346 deletions.
1 change: 1 addition & 0 deletions packages/access-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
},
"rules": {
"unicorn/prefer-number-properties": "off",
"@typescript-eslint/ban-types": "off",
"jsdoc/no-undefined-types": [
"error",
{
Expand Down
3 changes: 2 additions & 1 deletion packages/access-api/src/bindings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export interface Env {
SENTRY_DSN: string
POSTMARK_TOKEN: string
POSTMARK_SENDER?: string

/** CSV DIDs of services that can be used to provision spaces. */
PROVIDERS?: string
DEBUG_EMAIL?: string
LOGTAIL_TOKEN: string
// bindings
Expand Down
5 changes: 5 additions & 0 deletions packages/access-api/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export function loadConfig(env) {
PRIVATE_KEY: vars.PRIVATE_KEY,
DID: DID.parse(vars.DID).did(),

/** DIDs of services that can be used to provision spaces. */
PROVIDERS: env.PROVIDERS
? env.PROVIDERS.split(',').map((id) => DID.parse(id).did())
: [DID.parse(vars.DID).did()],

UPLOAD_API_URL: env.UPLOAD_API_URL || 'https://up.web3.storage/',
// bindings
METRICS:
Expand Down
107 changes: 87 additions & 20 deletions packages/access-api/src/models/provisions.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable no-void */
import { Failure } from '@ucanto/server'

/**
* @template {import("@ucanto/interface").DID} ServiceId
Expand All @@ -7,11 +8,11 @@

/**
* @template {import("@ucanto/interface").DID} ServiceId
* @param {ServiceId} service
* @param {ServiceId[]} services
* @param {Array<import("../types/provisions").Provision<ServiceId>>} storage
* @returns {Provisions<ServiceId>}
*/
export function createProvisions(service, storage = []) {
export function createProvisions(services, storage = []) {
/** @type {Provisions<ServiceId>['hasStorageProvider']} */
const hasStorageProvider = async (consumerId) => {
const hasRowWithSpace = storage.some(({ space }) => space === consumerId)
Expand All @@ -20,13 +21,14 @@ export function createProvisions(service, storage = []) {
/** @type {Provisions<ServiceId>['put']} */
const put = async (item) => {
storage.push(item)
return {}
}
/** @type {Provisions<ServiceId>['count']} */
const count = async () => {
return BigInt(storage.length)
}
return {
service,
services,
count,
put,
hasStorageProvider,
Expand Down Expand Up @@ -54,11 +56,11 @@ export class DbProvisions {
#db

/**
* @param {ServiceId} service
* @param {ServiceId[]} services
* @param {ProvisionsDatabase} db
*/
constructor(service, db) {
this.service = service
constructor(services, db) {
this.services = services
this.#db = db
this.tableNames = {
provisions: /** @type {const} */ ('provisions'),
Expand All @@ -75,6 +77,35 @@ export class DbProvisions {
return BigInt(size)
}

/**
* Selects all rows that match the query.
*
* @param {object} query
* @param {string} [query.space]
* @param {string} [query.provider]
* @param {string} [query.sponsor]
*/
async find(query = {}) {
const { provisions } = this.tableNames
let select = this.#db
.selectFrom(provisions)
.select(['cid', 'consumer', 'provider', 'sponsor'])

if (query.space) {
select = select.where(`${provisions}.consumer`, '=', query.space)
}

if (query.provider) {
select = select.where(`${provisions}.provider`, '=', query.provider)
}

if (query.sponsor) {
select = select.where(`${provisions}.sponsor`, '=', query.sponsor)
}

return await select.execute()
}

/** @type {Provisions<ServiceId>['put']} */
async put(item) {
/** @type {ProvisionsRow} */
Expand All @@ -84,6 +115,26 @@ export class DbProvisions {
provider: item.provider,
sponsor: item.account,
}

// We want to ensure that a space can not have provider of multiple types,
// e.g. a space can not have both a web3.storage and nft.storage providers
// otherwise it would be unclear where stored data should be added.
// Therefore we check look for any existing rows for this space, and if
// there is a row with a different provider, we error.
// Note that this does not give us transactional guarantees and in the face
// of concurrent requests, we may still end up with multiple providers
// however we soon intend to replace this table with one that has necessary
// constraints so we take this risk for now to avoid extra migration.
const matches = await this.find({ space: row.consumer })
const conflict = matches.find((row) => row.provider !== item.provider)
if (conflict) {
return new ConflictError({
message: `Space ${row.consumer} can not be provisioned with ${row.provider}, it already has a ${conflict.provider} provider`,
insertion: row,
existing: conflict,
})
}

/** @type {Array<keyof ProvisionsRow>} */
const rowColumns = ['cid', 'consumer', 'provider', 'sponsor']
const insert = this.#db
Expand All @@ -97,13 +148,13 @@ export class DbProvisions {
} catch (error) {
primaryKeyError = getCidUniquenessError(error)
if (!primaryKeyError) {
throw error
return new Failure(`Unexpected error inserting provision: ${error}`)
}
}

if (!primaryKeyError) {
// no error inserting, we're done with put
return
return {}
}

// there was already a row with this invocation cid
Expand All @@ -113,24 +164,25 @@ export class DbProvisions {
.selectFrom(this.tableNames.provisions)
.select(rowColumns)
.where('cid', '=', row.cid)
.executeTakeFirstOrThrow()
if (deepEqual(existing, row)) {
.executeTakeFirst()

if (!existing) {
return new Failure(`Unexpected error inserting provision`)
}

if (existing && deepEqual(existing, row)) {
// the insert failed, but the existing row is identical to the row that failed to insert.
// so the put is a no-op, and we can consider it a success despite encountering the primaryKeyError
return
return {}
}

// this is a sign of something very wrong. throw so error reporters can report on it
// and determine what led to a put() with same invocation cid but new non-cid column values
throw Object.assign(
new Error(
`Provision with cid ${item.invocation.cid} already exists with different field values`
),
{
insertion: row,
existing,
}
)
return new ConflictError({
message: `Provision with cid ${item.invocation.cid} already exists with different field values`,
insertion: row,
existing,
})
}

/** @type {Provisions<ServiceId>['hasStorageProvider']} */
Expand Down Expand Up @@ -191,6 +243,21 @@ function extractD1Error(error) {
return { cause, code }
}

class ConflictError extends Failure {
/**
* @param {object} input
* @param {string} input.message
* @param {unknown} input.insertion
* @param {unknown} input.existing
*/
constructor({ message, insertion, existing }) {
super(message)
this.name = 'ConflictError'
this.insertion = insertion
this.existing = existing
}
}

/**
* return whether or not the provided parameter indicates an error
* writing provision to kysely database because there is already an entry
Expand Down
1 change: 1 addition & 0 deletions packages/access-api/src/routes/root.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ export async function postRoot(request, env) {
body: new Uint8Array(await request.arrayBuffer()),
headers: Object.fromEntries(request.headers.entries()),
})

return new Response(rsp.body, { headers: rsp.headers })
}
13 changes: 9 additions & 4 deletions packages/access-api/src/service/provider-add.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,22 @@ export function createProviderAddHandler(options) {
message: 'Issuer must be a mailto DID',
}
}
if (provider !== options.provisions.service) {
throw new Error(`Provider must be ${options.provisions.service}`)
// @ts-expect-error provider might not be in service providers list - it ok!
if (!options.provisions.services.includes(provider)) {
return {
error: true,
name: 'InvalidProvider',
message: `Invalid provider: ${provider}`,
}
}
await options.provisions.put({

return await options.provisions.put({
invocation,
space: consumer,
// eslint-disable-next-line object-shorthand
provider: /** @type {ServiceId} */ (provider),
account: accountDID,
})
return {}
}
}

Expand Down
10 changes: 5 additions & 5 deletions packages/access-api/src/types/provisions.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
import * as Ucanto from '@ucanto/interface'
import { ProviderAdd } from '@web3-storage/capabilities/src/types'

export type AlphaStorageProvider = 'did:web:web3.storage:providers:w3up-alpha'

/**
* action which results in provisionment of a space consuming a storage provider
*/
export interface Provision<ServiceDID extends Ucanto.DID<'web'>> {
invocation: Ucanto.Invocation<ProviderAdd>
space: Ucanto.DID<'key'>
account: Ucanto.DID<'mailto'>
provider: AlphaStorageProvider | ServiceDID
provider: ServiceDID
}

/**
* stores instances of a storage provider being consumed by a consumer
*/
export interface ProvisionsStorage<ServiceDID extends Ucanto.DID<'web'>> {
service: ServiceDID
services: ServiceDID[]
hasStorageProvider: (consumer: Ucanto.DID<'key'>) => Promise<boolean>
/**
* ensure item is stored
*
* @param item - provision to store
*/
put: (item: Provision<ServiceDID>) => Promise<void>
put: (
item: Provision<ServiceDID>
) => Promise<Ucanto.Result<{}, Ucanto.Failure>>

/**
* get number of stored items
Expand Down
5 changes: 4 additions & 1 deletion packages/access-api/src/utils/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ export function getContext(request, env, ctx) {
spaces: new Spaces(config.DB),
validations: new Validations(config.VALIDATIONS),
accounts: new Accounts(config.DB),
provisions: new DbProvisions(signer.did(), createD1Database(config.DB)),
provisions: new DbProvisions(
config.PROVIDERS,
createD1Database(config.DB)
),
},
email,
uploadApi: createUploadApiConnection({
Expand Down
1 change: 0 additions & 1 deletion packages/access-api/test/access-client-agent.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,6 @@ function watchForEmail(emails, retryAfter, abort) {
}

/**
* @typedef {import('./provider-add.test.js').AccessAuthorize} AccessAuthorize
* @typedef {import('@web3-storage/capabilities/src/types.js').AccessConfirm} AccessConfirm
* @typedef {import('./helpers/ucanto-test-utils.js').AccessService} AccessService
*/
Expand Down
9 changes: 6 additions & 3 deletions packages/access-api/test/helpers/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dotenv.config({
*/
function createBindings(env) {
return {
...env,
ENV: 'test',
DEBUG: 'false',
DID: env.DID || 'did:web:test.web3.storage',
Expand All @@ -38,9 +39,11 @@ function createBindings(env) {
}

/**
* @param {object} options
* @param {Partial<AccessApiBindings>} [options.env] - environment variables to use when configuring access-api. Defaults to process.env.
* @param {unknown} [options.globals] - globals passed into miniflare
* @typedef {object} Options
* @property {Partial<AccessApiBindings>} [env] - environment variables to use when configuring access-api. Defaults to process.env.
* @property {Record<string, unknown>} [globals] - globals passed into miniflare
*
* @param {Options} options
*/
export async function context({ env = {}, globals } = {}) {
const bindings = createBindings({
Expand Down
Loading

0 comments on commit 96c5a2e

Please sign in to comment.