diff --git a/stacks/upload-api-stack.js b/stacks/upload-api-stack.js index da12a66e..3132d3ce 100644 --- a/stacks/upload-api-stack.js +++ b/stacks/upload-api-stack.js @@ -72,6 +72,7 @@ export function UploadApiStack({ stack, app }) { ACCESS_SERVICE_DID: process.env.ACCESS_SERVICE_DID ?? '', ACCESS_SERVICE_URL: process.env.ACCESS_SERVICE_URL ?? '', POSTMARK_TOKEN: process.env.POSTMARK_TOKEN ?? '', + PROVIDERS: process.env.PROVIDERS ?? '', R2_ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID ?? '', R2_SECRET_ACCESS_KEY: process.env.R2_SECRET_ACCESS_KEY ?? '', R2_REGION: process.env.R2_REGION ?? '', diff --git a/upload-api/config.js b/upload-api/config.js index 16248930..2fb04e9f 100644 --- a/upload-api/config.js +++ b/upload-api/config.js @@ -9,7 +9,7 @@ import * as DID from '@ipld/dag-ucan/did' * @param {string} [config.UPLOAD_API_DID] - public DID for the upload service (did:key:... derived from PRIVATE_KEY if not set) * @returns {import('@ucanto/principal/ed25519').Signer.Signer} */ - export function getServiceSigner(config) { +export function getServiceSigner(config) { const signer = ed25519.parse(config.PRIVATE_KEY) if (config.UPLOAD_API_DID) { const did = DID.parse(config.UPLOAD_API_DID).did() @@ -17,3 +17,21 @@ import * as DID from '@ipld/dag-ucan/did' } return signer } + +/** + * Given a string, parse into provider ServiceDIDs. + * + * @param {string} serviceDids a comma-separated string of ServiceDIDs + * @returns {import('@web3-storage/upload-api').ServiceDID[]} + */ +export function parseServiceDids(serviceDids) { + return /** @type {import('@web3-storage/upload-api').ServiceDID[]} */( + serviceDids.split(',').map(s => { + const did = DID.parse(s.trim()).did() + if (!did.startsWith('did:web:')) { + throw new Error(`Invalid ServiceDID - ServiceDID must be a did:web: ${did}`) + } + return did + }) + ) +} \ No newline at end of file diff --git a/upload-api/functions/ucan-invocation-router.js b/upload-api/functions/ucan-invocation-router.js index aa0f5244..3a45dd94 100644 --- a/upload-api/functions/ucan-invocation-router.js +++ b/upload-api/functions/ucan-invocation-router.js @@ -11,7 +11,7 @@ import { createTaskStore } from '../buckets/task-store.js' import { createWorkflowStore } from '../buckets/workflow-store.js' import { createStoreTable } from '../tables/store.js' import { createUploadTable } from '../tables/upload.js' -import { getServiceSigner } from '../config.js' +import { getServiceSigner, parseServiceDids } from '../config.js' import { createUcantoServer } from '../service.js' import { Config } from '@serverless-stack/node/config/index.js' import { CAR, Legacy, Codec } from '@ucanto/transport' @@ -95,9 +95,9 @@ export async function ucanInvocationRouter(request) { WORKFLOW_BUCKET_NAME: workflowBucketName = '', UCAN_LOG_STREAM_NAME: streamName = '', POSTMARK_TOKEN: postmarkToken = '', + PROVIDERS: providers = '', // set for testing DYNAMO_DB_ENDPOINT: dbEndpoint, - ACCESS_SERVICE_DID: accessServiceDID = '', ACCESS_SERVICE_URL: accessServiceURL = '', } = process.env @@ -125,10 +125,7 @@ export async function ucanInvocationRouter(request) { endpoint: dbEndpoint }); const rateLimitsStorage = createRateLimitTable(AWS_REGION, rateLimitTableName) - const provisionsStorage = useProvisionStore(subscriptionTable, consumerTable, [ - /** @type {import('@web3-storage/upload-api').ServiceDID} */ - (accessServiceDID) - ]) + const provisionsStorage = useProvisionStore(subscriptionTable, consumerTable, parseServiceDids(providers)) const delegationsStorage = createDelegationsTable(AWS_REGION, delegationTableName, { bucket: delegationBucket, invocationBucket, workflowBucket }) const server = createUcantoServer(serviceSigner, { diff --git a/upload-api/functions/validate-email.jsx b/upload-api/functions/validate-email.jsx index 4ddb2d05..da231d59 100644 --- a/upload-api/functions/validate-email.jsx +++ b/upload-api/functions/validate-email.jsx @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/serverless' import { authorize } from '@web3-storage/upload-api/validate' import { Config } from '@serverless-stack/node/config/index.js' -import { getServiceSigner } from '../config.js' +import { getServiceSigner, parseServiceDids } from '../config.js' import { Email } from '../email.js' import { createDelegationsTable } from '../tables/delegations.js' import { createDelegationsStore } from '../buckets/delegations-store.js' @@ -64,7 +64,6 @@ export const preValidateEmail = Sentry.AWSLambda.wrapHandler(validateEmailGet) function createAuthorizeContext () { const { ACCESS_SERVICE_URL = '', - ACCESS_SERVICE_DID = '', AWS_REGION = '', DELEGATION_TABLE_NAME = '', RATE_LIMIT_TABLE_NAME = '', @@ -78,6 +77,7 @@ function createAuthorizeContext () { SUBSCRIPTION_TABLE_NAME = '', CONSUMER_TABLE_NAME = '', UPLOAD_API_DID = '', + PROVIDERS = '', // set for testing DYNAMO_DB_ENDPOINT: dbEndpoint, } = process.env @@ -100,10 +100,7 @@ function createAuthorizeContext () { email: new Email({ token: POSTMARK_TOKEN }), signer: getServiceSigner({ UPLOAD_API_DID, PRIVATE_KEY }), delegationsStorage: createDelegationsTable(AWS_REGION, DELEGATION_TABLE_NAME, { bucket: delegationBucket, invocationBucket, workflowBucket }), - provisionsStorage: useProvisionStore(subscriptionTable, consumerTable, [ - /** @type {import('@web3-storage/upload-api').ServiceDID} */ - (ACCESS_SERVICE_DID) - ]), + provisionsStorage: useProvisionStore(subscriptionTable, consumerTable, parseServiceDids(PROVIDERS)), rateLimitsStorage: createRateLimitTable(AWS_REGION, RATE_LIMIT_TABLE_NAME) } } diff --git a/upload-api/test/config.test.js b/upload-api/test/config.test.js index 773654ea..775c73af 100644 --- a/upload-api/test/config.test.js +++ b/upload-api/test/config.test.js @@ -57,3 +57,48 @@ test('getServiceSigner errors if config.UPLOAD_API_DID is provided but not a DID }) }, { message: /^Invalid DID/ }) }) + +test('parseServiceDids parses one DID', async (t) => { + t.deepEqual( + configModule.parseServiceDids('did:web:example.com'), + ['did:web:example.com'] + ) +}) + +test('parseServiceDids parses more than one DID', async (t) => { + t.deepEqual( + configModule.parseServiceDids('did:web:example.com,did:web:two.example.com'), + ['did:web:example.com', 'did:web:two.example.com'] + ) + + t.deepEqual( + configModule.parseServiceDids('did:web:example.com,did:web:two.example.com,did:web:three.example.com'), + ['did:web:example.com', 'did:web:two.example.com', 'did:web:three.example.com'] + ) +}) + +test('parseServiceDids trims space around dids', async (t) => { + t.deepEqual( + configModule.parseServiceDids(' did:web:example.com, did:web:two.example.com '), + ['did:web:example.com', 'did:web:two.example.com'] + ) +}) + +test('parseServiceDids throws an exception if a non-DID is provided', async (t) => { + t.throws( + () => configModule.parseServiceDids('http://example.com'), + { message: /^Invalid DID/} + ) +}) + +test('parseServiceDids throws an exception if a non-ServiceDID is provided', async (t) => { + t.throws( + () => configModule.parseServiceDids('did:mailto:abc123'), + { message: /^Invalid ServiceDID/} + ) + + t.throws( + () => configModule.parseServiceDids('did:key:z6Mkfy8k2JJUdNWCJtvzYrko5QRc7GXP6pksKDG19gxYzyi4'), + { message: /^Invalid ServiceDID/} + ) +}) \ No newline at end of file