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: allow multiple providers #595

Merged
merged 14 commits into from
Mar 23, 2023
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I would personally loose the generic and use Ucanto.DID<'web'> here. Which I think would make ts-expect-error in file above go away as well.

}

/**
* stores instances of a storage provider being consumed by a consumer
*/
export interface ProvisionsStorage<ServiceDID extends Ucanto.DID<'web'>> {
service: ServiceDID
services: ServiceDID[]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I'd remove these from the interface entirely. When I was designing provider protocol I was assuming that we would have provider/publish capability which would allow you to create a provider and consequently allow others to add it if they received consumer/add delegation from you. I realize we're far from there, but it still might be good idea to align on it. Implementation could still have such a field.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did start out that way but it's a bigger change set and neither of you were awake for me to double check with ;)

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