diff --git a/.env.tpl b/.env.tpl index 1c61b2de2..da41927cb 100644 --- a/.env.tpl +++ b/.env.tpl @@ -6,6 +6,9 @@ POSTMARK_TOKEN=secret SENTRY_DSN=https://000000@0000000.ingest.sentry.io/00000 LOGTAIL_TOKEN=secret +# Set to false if you want to send emails with postmark instead +DEBUG_EMAIL=true + # CI secrets SENTRY_TOKEN=secret -SENTRY_UPLOAD=false \ No newline at end of file +SENTRY_UPLOAD=false diff --git a/packages/access-api/src/bindings.d.ts b/packages/access-api/src/bindings.d.ts index 5b2274928..d0f8aadf6 100644 --- a/packages/access-api/src/bindings.d.ts +++ b/packages/access-api/src/bindings.d.ts @@ -5,7 +5,6 @@ import type { SpaceTable, } from '@web3-storage/access/types' import type { Handler as _Handler } from '@web3-storage/worker-utils/router' -import { Email } from './utils/email.js' import { Spaces } from './models/spaces.js' import { Validations } from './models/validations.js' import { loadConfig } from './config.js' @@ -25,6 +24,11 @@ export interface AnalyticsEngineEvent { readonly blobs?: Array } +export interface Email { + sendValidation: ({ to: string, url: string }) => Promise + send: ({ to: string, textBody: string, subject: string }) => Promise +} + export interface Env { // vars ENV: string @@ -41,6 +45,8 @@ export interface Env { SENTRY_DSN: string POSTMARK_TOKEN: string POSTMARK_SENDER?: string + + DEBUG_EMAIL?: string LOGTAIL_TOKEN: string // bindings SPACES: KVNamespace diff --git a/packages/access-api/src/service/access-authorize.js b/packages/access-api/src/service/access-authorize.js index 27e4b6139..b43094bca 100644 --- a/packages/access-api/src/service/access-authorize.js +++ b/packages/access-api/src/service/access-authorize.js @@ -37,15 +37,13 @@ export function accessAuthorizeProvider(ctx) { await ctx.models.accounts.create(capability.nb.iss) const url = `${ctx.url.protocol}//${ctx.url.host}/validate-email?ucan=${encoded}&mode=session` - // For testing - if (ctx.config.ENV === 'test') { - return url - } await ctx.email.sendValidation({ to: Mailto.toEmail(capability.nb.iss), url, }) + + return {} } ) } diff --git a/packages/access-api/src/utils/context.js b/packages/access-api/src/utils/context.js index 79984a9ce..26a957f89 100644 --- a/packages/access-api/src/utils/context.js +++ b/packages/access-api/src/utils/context.js @@ -6,7 +6,7 @@ import { loadConfig } from '../config.js' import { Accounts } from '../models/accounts.js' import { Spaces } from '../models/spaces.js' import { Validations } from '../models/validations.js' -import { Email } from './email.js' +import * as Email from './email.js' import { createUploadApiConnection } from '../service/upload-api-proxy.js' import { DID } from '@ucanto/core' import { DbDelegationsStorage } from '../models/delegations.js' @@ -22,6 +22,14 @@ import { createD1Database } from './d1.js' */ export function getContext(request, env, ctx) { const config = loadConfig(env) + const email = + config.ENV === 'test' || + (config.ENV === 'dev' && env.DEBUG_EMAIL === 'true') + ? Email.debug() + : Email.configure({ + token: config.POSTMARK_TOKEN, + sender: config.POSTMARK_SENDER, + }) // Sentry const sentry = new Toucan({ @@ -61,10 +69,7 @@ export function getContext(request, env, ctx) { validations: new Validations(config.VALIDATIONS), accounts: new Accounts(config.DB), }, - email: new Email({ - token: config.POSTMARK_TOKEN, - sender: config.POSTMARK_SENDER, - }), + email, uploadApi: createUploadApiConnection({ audience: DID.parse(config.DID).did(), url: new URL(config.UPLOAD_API_URL), diff --git a/packages/access-api/src/utils/email.js b/packages/access-api/src/utils/email.js index f12dfeb59..cca19719c 100644 --- a/packages/access-api/src/utils/email.js +++ b/packages/access-api/src/utils/email.js @@ -1,6 +1,12 @@ +export const debug = () => new DebugEmail() + +/** + * @param {{token:string, sender?:string}} opts + */ +export const configure = (opts) => new Email(opts) + export class Email { /** - * * @param {object} opts * @param {string} opts.token * @param {string} [opts.sender] @@ -75,3 +81,43 @@ export class Email { } } } + +/** + * This is API compatible version of Email class that can be used during + * tests and debugging. + */ +export class DebugEmail { + /** + * Send validation email with ucan to register + * + * @param {{ to: string; url: string }} opts + */ + async sendValidation(opts) { + try { + // @ts-expect-error + globalThis.email.sendValidation(opts) + } catch { + // eslint-disable-next-line no-console + console.log('email.sendValidation', opts) + } + } + + /** + * Send email + * + * @param {object} opts + * @param {string} opts.to + * @param {string} opts.textBody + * @param {string} opts.subject + * + */ + async send(opts) { + try { + // @ts-expect-error + globalThis.email.send(opts) + } catch { + // eslint-disable-next-line no-console + console.log('email.send', opts) + } + } +} diff --git a/packages/access-api/test/access-authorize.test.js b/packages/access-api/test/access-authorize.test.js index d66bd22bb..d0ffaa69a 100644 --- a/packages/access-api/test/access-authorize.test.js +++ b/packages/access-api/test/access-authorize.test.js @@ -19,8 +19,22 @@ const t = assert describe('access/authorize', function () { /** @type {Awaited>} */ let ctx + /** @type {{to:string, url:string}[]} */ + let outbox beforeEach(async function () { - ctx = await context() + outbox = [] + ctx = await context({ + globals: { + email: { + /** + * @param {*} email + */ + sendValidation(email) { + outbox.push(email) + }, + }, + }, + }) }) it('should issue ./update', async function () { @@ -39,14 +53,14 @@ describe('access/authorize', function () { }) .execute(conn) - if (!inv) { - return assert.fail('no output') - } if (inv.error) { return assert.fail(inv.message) } - const url = new URL(inv) + const [email] = outbox + assert.notEqual(email, undefined, 'no email was sent') + + const url = new URL(email.url) const encoded = /** @type {import('@web3-storage/access/types').EncodedDelegation<[import('@web3-storage/capabilities/types').AccessAuthorize]>} */ ( url.searchParams.get('ucan') @@ -90,14 +104,16 @@ describe('access/authorize', function () { }) .execute(conn) - if (!inv) { - return assert.fail('no output') - } if (inv.error) { return assert.fail(inv.message) } - const url = new URL(inv) + const [email] = outbox + if (!inv) { + return assert.fail('no email was sent') + } + + const url = new URL(email.url) const encoded = /** @type {import('@web3-storage/access/types').EncodedDelegation<[import('@web3-storage/capabilities/types').AccessAuthorize]>} */ ( url.searchParams.get('ucan') @@ -126,10 +142,11 @@ describe('access/authorize', function () { }) .execute(conn) - // @todo - this only returns string when ENV==='test'. Remove that env-specific behavior - assert.ok(typeof inv === 'string', 'invocation result is a string') + assert.equal(inv.error, undefined, 'invocation should not fail') + const [email] = outbox + assert.notEqual(email, undefined, 'email was sent') - const confirmEmailPostUrl = new URL(inv) + const confirmEmailPostUrl = new URL(email.url) const confirmEmailPostResponse = await mf.dispatchFetch( confirmEmailPostUrl, { method: 'POST' } @@ -218,14 +235,14 @@ describe('access/authorize', function () { }) .execute(conn) - if (!inv) { - return assert.fail('no output') - } if (inv.error) { return assert.fail(inv.message) } - const url = new URL(inv) + const [email] = outbox + assert.notEqual(email, undefined, 'email was sent') + + const url = new URL(email.url) // click email url await mf.dispatchFetch(url, { method: 'POST' }) diff --git a/packages/access-api/test/helpers/context.js b/packages/access-api/test/helpers/context.js index 366a90daa..0d4a731ad 100644 --- a/packages/access-api/test/helpers/context.js +++ b/packages/access-api/test/helpers/context.js @@ -38,9 +38,11 @@ function createBindings(env) { } /** - * @param {Partial} env - environment variables to use when configuring access-api. Defaults to process.env. + * @param {object} options + * @param {Partial} [options.env] - environment variables to use when configuring access-api. Defaults to process.env. + * @param {unknown} [options.globals] - globals passed into miniflare */ -export async function context(env = {}) { +export async function context({ env = {}, globals } = {}) { const bindings = createBindings({ ...process.env, ...env, @@ -57,6 +59,7 @@ export async function context(env = {}) { d1Persist: undefined, buildCommand: undefined, log: new Log(LogLevel.ERROR), + ...(globals ? { globals } : {}), }) const binds = await mf.getBindings() diff --git a/packages/access-api/test/store-list.js b/packages/access-api/test/store-list.js index cc27326ce..83bb01fe8 100644 --- a/packages/access-api/test/store-list.js +++ b/packages/access-api/test/store-list.js @@ -39,13 +39,15 @@ describe('proxy store/list invocations to upload-api', function () { // and if it's present, the assertions will expect no error from the proxy or upstream const privateKeyFromEnv = process.env.WEB3_STORAGE_PRIVATE_KEY const { issuer, service, conn } = await context({ - // this emulates the configuration for deployed environments, - // which will allow the access-api ucanto server to accept - // invocations where aud=web3storageDid - DID: web3storageDid, - // @ts-ignore - PRIVATE_KEY: privateKeyFromEnv ?? process.env.PRIVATE_KEY, - UPLOAD_API_URL: mockUpstreamUrl.toString(), + env: { + // this emulates the configuration for deployed environments, + // which will allow the access-api ucanto server to accept + // invocations where aud=web3storageDid + DID: web3storageDid, + // @ts-ignore + PRIVATE_KEY: privateKeyFromEnv ?? process.env.PRIVATE_KEY, + UPLOAD_API_URL: mockUpstreamUrl.toString(), + }, }) const spaceCreation = await createSpace( issuer, @@ -89,7 +91,9 @@ describe('proxy store/list invocations to upload-api', function () { Array.from({ length: 3 }).map(() => ed25519.Signer.generate()) ) const { service: serviceSigner, conn } = await context({ - UPLOAD_API_URL: mockUpstreamUrl.toString(), + env: { + UPLOAD_API_URL: mockUpstreamUrl.toString(), + }, }) const service = process.env.DID ? serviceSigner.withDID(ucanto.DID.parse(process.env.DID).did()) diff --git a/packages/access-api/test/ucan.test.js b/packages/access-api/test/ucan.test.js index 22ed581fc..c8cfc69ee 100644 --- a/packages/access-api/test/ucan.test.js +++ b/packages/access-api/test/ucan.test.js @@ -147,8 +147,10 @@ describe('ucan', function () { test('should support ucan invoking to a did:web aud', async function () { const serviceDidWeb = 'did:web:example.com' const { mf, issuer, service } = await context({ - ...process.env, - DID: serviceDidWeb, + env: { + ...process.env, + DID: serviceDidWeb, + }, }) const ucan = await UCAN.issue({ issuer, diff --git a/packages/access-api/test/upload-api-proxy.test.js b/packages/access-api/test/upload-api-proxy.test.js index 7eec954bf..9c4e4c43a 100644 --- a/packages/access-api/test/upload-api-proxy.test.js +++ b/packages/access-api/test/upload-api-proxy.test.js @@ -48,9 +48,11 @@ function testCanProxyInvocation(can) { }) const mockUpstreamUrl = serverLocalUrl(mockUpstreamHttp.address()) const { issuer, conn } = await context({ - UPLOAD_API_URL: mockUpstreamUrl.toString(), - // @ts-expect-error This expects did:web - DID: upstreamPrincipal.did(), + env: { + UPLOAD_API_URL: mockUpstreamUrl.toString(), + // @ts-expect-error This expects did:web + DID: upstreamPrincipal.did(), + }, }) /** @type {Ucanto.ConnectionView} */ const connection = conn diff --git a/packages/access-client/src/types.ts b/packages/access-client/src/types.ts index bcc1ed57a..bbf922c29 100644 --- a/packages/access-client/src/types.ts +++ b/packages/access-client/src/types.ts @@ -33,6 +33,7 @@ import type { VoucherRedeem, Top, AccessAuthorize, + AccessAuthorizeSuccess, AccessDelegate, AccessDelegateFailure, AccessDelegateSuccess, @@ -95,8 +96,7 @@ export interface SpaceTableMetadata { */ export interface Service { access: { - // returns a URL string for tests or nothing in other envs - authorize: ServiceMethod + authorize: ServiceMethod claim: ServiceMethod delegate: ServiceMethod< AccessDelegate, diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index 0d2d30757..8bb1ff72a 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -8,6 +8,8 @@ import * as UploadCaps from './upload.js' import { claim, redeem } from './voucher.js' import * as AccessCaps from './access.js' +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface Unit {} /** * failure due to a resource not having enough storage capacity. */ @@ -22,6 +24,9 @@ export type Access = InferInvokedCapability export type AccessAuthorize = InferInvokedCapability< typeof AccessCaps.authorize > + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export type AccessAuthorizeSuccess = Unit export type AccessClaim = InferInvokedCapability export interface AccessClaimSuccess { delegations: Record>