diff --git a/apps/blog-next/app/api/verify-email/resend/route.ts b/apps/blog-next/app/api/verify-email/resend/route.ts index 4edc3ef..cc1c02a 100644 --- a/apps/blog-next/app/api/verify-email/resend/route.ts +++ b/apps/blog-next/app/api/verify-email/resend/route.ts @@ -1,42 +1,28 @@ -import { verification } from '@holo-js/auth' +import { resendEmailVerification } from '@holo-js/auth' +import { validate } from '@holo-js/forms' -interface ResendVerificationRequestBody { - readonly email?: string -} +import { resendEmailVerificationForm } from '@/lib/schemas/auth' -async function readRequestBody(request: Request): Promise { - const contentType = request.headers.get('content-type') ?? '' - if (!contentType.includes('application/json')) { - return {} - } +export async function POST(request: Request) { + const submission = await validate(request, resendEmailVerificationForm) - const payload = await request.json().catch(() => null) - if (!payload || typeof payload !== 'object') { - return {} + if (!submission.valid) { + return Response.json(submission.fail(), { + status: submission.fail().status, + }) } - const email = typeof payload.email === 'string' ? payload.email.trim() : undefined - return email ? { email } : {} -} - -export async function POST(request: Request) { - const input = await readRequestBody(request) - const { error } = await verification.resend(input) + const { error } = await resendEmailVerification(submission.data.email) if (error) { - return Response.json({ - ok: false as const, + const failure = submission.fail({ status: error.status, errors: error.fields, - }, { - status: error.status, }) + + return Response.json(failure, { status: failure.status }) } - return Response.json({ - ok: true as const, - status: 200, - data: { - message: 'A fresh verification email has been sent.', - }, - }) + return Response.json(submission.success({ + message: 'A fresh verification email has been sent.', + })) } diff --git a/apps/blog-next/app/api/verify-email/route.ts b/apps/blog-next/app/api/verify-email/route.ts index a0c552a..bc508ee 100644 --- a/apps/blog-next/app/api/verify-email/route.ts +++ b/apps/blog-next/app/api/verify-email/route.ts @@ -1,4 +1,4 @@ -import { check, verification } from '@holo-js/auth' +import { check, verifyEmail } from '@holo-js/auth' import { validate } from '@holo-js/forms' import { verifyEmailForm } from '@/lib/schemas/auth' @@ -13,7 +13,7 @@ export async function POST(request: Request) { } const wasAuthenticated = await check() - const { error } = await verification.consume(submission.data.token) + const { error } = await verifyEmail(submission.data.token) if (error) { const failure = submission.fail({ status: error.status, diff --git a/apps/blog-next/lib/schemas/auth.ts b/apps/blog-next/lib/schemas/auth.ts index cb5bd8e..03ef8a1 100644 --- a/apps/blog-next/lib/schemas/auth.ts +++ b/apps/blog-next/lib/schemas/auth.ts @@ -17,6 +17,10 @@ export const forgotPasswordForm = schema({ email: field.string().required('Email is required.').email('Enter a valid email address.'), }) +export const resendEmailVerificationForm = schema({ + email: field.string().required('Email is required.').email('Enter a valid email address.'), +}) + export const resetPasswordForm = schema({ token: field.string().required('Reset token is required.'), password: field.password().required('Password is required.').min(8, 'Password must be at least 8 characters.').confirmed(), diff --git a/apps/blog-nuxt/server/api/verify-email.post.ts b/apps/blog-nuxt/server/api/verify-email.post.ts index bae608c..3d1ac8f 100644 --- a/apps/blog-nuxt/server/api/verify-email.post.ts +++ b/apps/blog-nuxt/server/api/verify-email.post.ts @@ -1,4 +1,4 @@ -import { check, verification } from '@holo-js/auth' +import { check, verifyEmail } from '@holo-js/auth' import { validate } from '@holo-js/forms' import { verifyEmailForm } from '#shared/schemas/auth' @@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => { } const wasAuthenticated = await check() - const { error } = await verification.consume(submission.data.token) + const { error } = await verifyEmail(submission.data.token) if (error) { const failure = submission.fail({ status: error.status, diff --git a/apps/blog-nuxt/server/api/verify-email/resend.post.ts b/apps/blog-nuxt/server/api/verify-email/resend.post.ts index 790cd8d..a01f32d 100644 --- a/apps/blog-nuxt/server/api/verify-email/resend.post.ts +++ b/apps/blog-nuxt/server/api/verify-email/resend.post.ts @@ -1,36 +1,29 @@ -import { verification } from '@holo-js/auth' +import { resendEmailVerification } from '@holo-js/auth' +import { validate } from '@holo-js/forms' -interface ResendVerificationRequestBody { - readonly email?: string -} +import { resendEmailVerificationForm } from '#shared/schemas/auth' export default defineEventHandler(async (event) => { - let payload: ResendVerificationRequestBody | null = {} - try { - payload = await readBody(event) - } catch { - payload = {} + const submission = await validate(event, resendEmailVerificationForm) + + if (!submission.valid) { + const failure = submission.fail() + setResponseStatus(event, failure.status) + return failure } - const email = typeof payload === 'object' - && payload !== null - && typeof payload.email === 'string' - ? payload.email.trim() - : '' - const { error } = await verification.resend(email ? { email } : undefined) + + const { error } = await resendEmailVerification(submission.data.email) if (error) { - setResponseStatus(event, error.status) - return { - ok: false as const, + const failure = submission.fail({ status: error.status, errors: error.fields, - } - } + }) - return { - ok: true as const, - status: 200, - data: { - message: 'A fresh verification email has been sent.', - }, + setResponseStatus(event, failure.status) + return failure } + + return submission.success({ + message: 'A fresh verification email has been sent.', + }) }) diff --git a/apps/blog-nuxt/shared/schemas/auth.ts b/apps/blog-nuxt/shared/schemas/auth.ts index cb5bd8e..03ef8a1 100644 --- a/apps/blog-nuxt/shared/schemas/auth.ts +++ b/apps/blog-nuxt/shared/schemas/auth.ts @@ -17,6 +17,10 @@ export const forgotPasswordForm = schema({ email: field.string().required('Email is required.').email('Enter a valid email address.'), }) +export const resendEmailVerificationForm = schema({ + email: field.string().required('Email is required.').email('Enter a valid email address.'), +}) + export const resetPasswordForm = schema({ token: field.string().required('Reset token is required.'), password: field.password().required('Password is required.').min(8, 'Password must be at least 8 characters.').confirmed(), diff --git a/apps/blog-sveltekit/src/lib/schemas/auth.ts b/apps/blog-sveltekit/src/lib/schemas/auth.ts index cb5bd8e..03ef8a1 100644 --- a/apps/blog-sveltekit/src/lib/schemas/auth.ts +++ b/apps/blog-sveltekit/src/lib/schemas/auth.ts @@ -17,6 +17,10 @@ export const forgotPasswordForm = schema({ email: field.string().required('Email is required.').email('Enter a valid email address.'), }) +export const resendEmailVerificationForm = schema({ + email: field.string().required('Email is required.').email('Enter a valid email address.'), +}) + export const resetPasswordForm = schema({ token: field.string().required('Reset token is required.'), password: field.password().required('Password is required.').min(8, 'Password must be at least 8 characters.').confirmed(), diff --git a/apps/blog-sveltekit/src/routes/api/verify-email/+server.ts b/apps/blog-sveltekit/src/routes/api/verify-email/+server.ts index 48d3e75..8c6dc4f 100644 --- a/apps/blog-sveltekit/src/routes/api/verify-email/+server.ts +++ b/apps/blog-sveltekit/src/routes/api/verify-email/+server.ts @@ -1,5 +1,5 @@ import { json } from '@sveltejs/kit' -import { check, verification } from '@holo-js/auth' +import { check, verifyEmail } from '@holo-js/auth' import { validate } from '@holo-js/forms' import { verifyEmailForm } from '$lib/schemas/auth' @@ -14,7 +14,7 @@ export async function POST({ request }: { request: Request }) { } const authenticationCheck = check() - const verificationResult = verification.consume(submission.data.token) + const verificationResult = verifyEmail(submission.data.token) const [wasAuthenticated, { error }] = await Promise.all([authenticationCheck, verificationResult]) if (error) { const failure = submission.fail({ diff --git a/apps/blog-sveltekit/src/routes/api/verify-email/resend/+server.ts b/apps/blog-sveltekit/src/routes/api/verify-email/resend/+server.ts index edd2f76..e7f1c24 100644 --- a/apps/blog-sveltekit/src/routes/api/verify-email/resend/+server.ts +++ b/apps/blog-sveltekit/src/routes/api/verify-email/resend/+server.ts @@ -1,43 +1,29 @@ import { json } from '@sveltejs/kit' -import { verification } from '@holo-js/auth' +import { resendEmailVerification } from '@holo-js/auth' +import { validate } from '@holo-js/forms' -interface ResendVerificationRequestBody { - readonly email?: string -} +import { resendEmailVerificationForm } from '$lib/schemas/auth' -async function readRequestBody(request: Request): Promise { - const contentType = request.headers.get('content-type') ?? '' - if (!contentType.includes('application/json')) { - return {} - } +export async function POST({ request }: { request: Request }) { + const submission = await validate(request, resendEmailVerificationForm) - const payload = await request.json().catch(() => null) - if (!payload || typeof payload !== 'object') { - return {} + if (!submission.valid) { + return json(submission.fail(), { + status: submission.fail().status, + }) } - const email = typeof payload.email === 'string' ? payload.email.trim() : undefined - return email ? { email } : {} -} - -export async function POST({ request }: { request: Request }) { - const input = await readRequestBody(request) - const { error } = await verification.resend(input) + const { error } = await resendEmailVerification(submission.data.email) if (error) { - return json({ - ok: false as const, + const failure = submission.fail({ status: error.status, errors: error.fields, - }, { - status: error.status, }) + + return json(failure, { status: failure.status }) } - return json({ - ok: true as const, - status: 200, - data: { - message: 'A fresh verification email has been sent.', - }, - }) + return json(submission.success({ + message: 'A fresh verification email has been sent.', + })) } diff --git a/apps/docs/docs/auth/email-verification.md b/apps/docs/docs/auth/email-verification.md index d7a24e2..8b4912c 100644 --- a/apps/docs/docs/auth/email-verification.md +++ b/apps/docs/docs/auth/email-verification.md @@ -38,7 +38,7 @@ AUTH_EMAIL_VERIFICATION_ROUTE=/verify-email `APP_URL` is used when the framework builds the email link. Applications should not manually construct the verification URL in normal usage. -The application still owns the verification page and the route that calls `verification.consume(...)`. The framework +The application still owns the verification page and the route that calls `verifyEmail(token)`. The framework owns the redirect target and the generated email link. ## Registration Flow @@ -110,30 +110,36 @@ That lets the app redirect the signed-in user to the verify page instead of reje ## Consuming Verification Tokens -Verification pages consume the token from the emailed link: +Verification pages verify the token from the emailed link: ```ts -import { verification } from '@holo-js/auth' +import { verifyEmail } from '@holo-js/auth' -const { data: verifiedUser, error } = await verification.consume(token) +const { data: verifiedUser, error } = await verifyEmail(token) ``` The verification flow marks the local user as verified and invalidates the token. ## Resending Verification Emails -Applications can resend another verification email with plain object input: +Applications can resend another verification email with a direct email argument: ```ts -import { verification } from '@holo-js/auth' +import { resendEmailVerification } from '@holo-js/auth' -const { error } = await verification.resend({ - email: body.email, -}) +const { error } = await resendEmailVerification(body.email) ``` This is the intended verify-page flow when the user lands on `/verify-email?email=...` after login. +When you are sending a verification email outside a resend route, use the same API shape with the send-oriented name: + +```ts +import { sendEmailVerification } from '@holo-js/auth' + +const { error } = await sendEmailVerification(body.email) +``` + Expected resend failures come back in `error`, for example: - `email_verification_user_missing` diff --git a/apps/docs/docs/security.md b/apps/docs/docs/security.md index 501854e..8f63fb7 100644 --- a/apps/docs/docs/security.md +++ b/apps/docs/docs/security.md @@ -116,7 +116,7 @@ When `@holo-js/forms` is installed, forms can opt into security directly through Validation failures and auth failures stay separate: - `validate(...)` returns form validation failures such as missing fields, bad formats, CSRF errors, and throttling. -- `login(...)`, `register(...)`, `verification.consume(...)`, `requestPasswordReset(...)`, and `resetPassword(...)` return auth failures in `error`. +- `login(...)`, `register(...)`, `verifyEmail(...)`, `requestPasswordReset(...)`, and `resetPassword(...)` return auth failures in `error`. - Auth failures are plain data with `status` and `fields`, so routes can forward them directly into the normal form response shape. ### Login diff --git a/packages/auth/src/contracts.ts b/packages/auth/src/contracts.ts index 997e4d4..402eb69 100644 --- a/packages/auth/src/contracts.ts +++ b/packages/auth/src/contracts.ts @@ -183,6 +183,11 @@ export interface AuthPasswordResetRequestOptions { readonly expiresAt?: Date } +export interface AuthEmailVerificationSendOptions { + readonly guard?: string + readonly expiresAt?: Date +} + export interface AuthSessionLoginOptions { readonly remember?: boolean } @@ -234,6 +239,15 @@ export interface AuthFacade extends AuthGuardFacade { resetPassword( input: TInput, ): Promise>> + verifyEmail(token: string): Promise>> + sendEmailVerification(): Promise>> + sendEmailVerification(email: string): Promise>> + sendEmailVerification(email: string | undefined): Promise>> + sendEmailVerification(email: string | undefined, options: AuthEmailVerificationSendOptions): Promise>> + resendEmailVerification(): Promise>> + resendEmailVerification(email: string): Promise>> + resendEmailVerification(email: string | undefined): Promise>> + resendEmailVerification(email: string | undefined, options: AuthEmailVerificationSendOptions): Promise>> hashPassword(password: string): Promise verifyPassword(password: string, digest: string): Promise needsPasswordRehash(digest: string): Promise diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 63422a9..135ddaa 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,4 +1,4 @@ -import { check, currentAccessToken, getAuthRuntime, hashPassword, id, impersonate, impersonateById, impersonation, login, loginUsing, loginUsingId, logout, needsPasswordRehash, refreshUser, register, requestPasswordReset, resetPassword, stopImpersonating, tokens, user, verification, verifyPassword } from './runtime' +import { check, currentAccessToken, getAuthRuntime, hashPassword, id, impersonate, impersonateById, impersonation, login, loginUsing, loginUsingId, logout, needsPasswordRehash, refreshUser, register, requestPasswordReset, resendEmailVerification, resetPassword, sendEmailVerification, stopImpersonating, tokens, user, verification, verifyEmail, verifyPassword } from './runtime' export { AUTH_ERROR_CODES, AuthError, defineAuthConfig, isAuthError } from './contracts' export { @@ -21,12 +21,15 @@ export { refreshUser, register, requestPasswordReset, + resendEmailVerification, resetAuthRuntime, resetPassword, + sendEmailVerification, stopImpersonating, tokens, user, verification, + verifyEmail, verifyPassword, } from './runtime' export type { @@ -40,6 +43,7 @@ export type { AuthEmailVerificationConsumeErrorCode, AuthEmailVerificationFacade, AuthEmailVerificationResendErrorCode, + AuthEmailVerificationSendOptions, AuthEstablishedSession, AuthFacade, AuthFieldErrors, @@ -104,10 +108,13 @@ const auth = Object.freeze({ needsPasswordRehash, register, requestPasswordReset, + resendEmailVerification, resetPassword, + sendEmailVerification, stopImpersonating, tokens, verification, + verifyEmail, verifyPassword, guard(name: string) { return getAuthRuntime().guard(name) diff --git a/packages/auth/src/runtime.ts b/packages/auth/src/runtime.ts index 02cc585..db46889 100644 --- a/packages/auth/src/runtime.ts +++ b/packages/auth/src/runtime.ts @@ -8,6 +8,7 @@ import type { AuthEmailVerificationConsumeErrorCode, AuthEmailVerificationFacade, AuthEmailVerificationResendErrorCode, + AuthEmailVerificationSendOptions, AuthEstablishedSession, AuthFacade, AuthGuardFacade, @@ -1871,6 +1872,16 @@ function createEmailVerificationFacade(): AuthEmailVerificationFacade { }) } +function createEmailVerificationResendInput( + email?: string, + options: AuthEmailVerificationSendOptions = {}, +): { readonly guard?: string, readonly expiresAt?: Date, readonly email?: string } { + return { + ...options, + ...(typeof email === 'string' ? { email } : {}), + } +} + async function requestPasswordResetUsingRuntime( input: TInput, options: AuthPasswordResetRequestOptions = {}, @@ -2221,6 +2232,15 @@ export function getAuthRuntime(): AuthRuntimeFacade { resetPassword(input: TInput) { return resetPasswordUsingRuntime(input) }, + verifyEmail(token: string) { + return verification.consume(token) + }, + sendEmailVerification(email?: string, options?: AuthEmailVerificationSendOptions) { + return verification.resend(createEmailVerificationResendInput(email, options)) + }, + resendEmailVerification(email?: string, options?: AuthEmailVerificationSendOptions) { + return verification.resend(createEmailVerificationResendInput(email, options)) + }, hashPassword(password: string) { return getRuntimeBindings().passwordHasher.hash(password) }, @@ -2372,6 +2392,34 @@ export async function resetPassword( return getAuthRuntime().resetPassword(input) } +export function verifyEmail( + token: string, +): Promise>> { + return getAuthRuntime().verifyEmail(token) +} + +export function sendEmailVerification(): Promise>> +export function sendEmailVerification(email: string): Promise>> +export function sendEmailVerification(email: string | undefined): Promise>> +export function sendEmailVerification(email: string | undefined, options: AuthEmailVerificationSendOptions): Promise>> +export function sendEmailVerification( + email?: string, + options?: AuthEmailVerificationSendOptions, +): Promise>> { + return getAuthRuntime().verification.resend(createEmailVerificationResendInput(email, options)) +} + +export function resendEmailVerification(): Promise>> +export function resendEmailVerification(email: string): Promise>> +export function resendEmailVerification(email: string | undefined): Promise>> +export function resendEmailVerification(email: string | undefined, options: AuthEmailVerificationSendOptions): Promise>> +export function resendEmailVerification( + email?: string, + options?: AuthEmailVerificationSendOptions, +): Promise>> { + return getAuthRuntime().verification.resend(createEmailVerificationResendInput(email, options)) +} + export const tokens: AuthTokenFacade = Object.freeze({ create(user: unknown, options: PersonalAccessTokenCreationOptions) { return getAuthRuntime().tokens.create(user, options) diff --git a/packages/auth/tests/contracts.type.test.ts b/packages/auth/tests/contracts.type.test.ts index f68f62e..e96f1b4 100644 --- a/packages/auth/tests/contracts.type.test.ts +++ b/packages/auth/tests/contracts.type.test.ts @@ -1,5 +1,5 @@ import { describe, expectTypeOf, it } from 'vitest' -import auth, { AuthError, isAuthError, type AuthErrorCode, type AuthEstablishedSession, type AuthFailure, type AuthGuardFacade, type AuthImpersonationState, type AuthLoginErrorCode, type AuthLogoutResult, type AuthPasswordResetConsumeErrorCode, type AuthPasswordResetRequestErrorCode, type AuthProviderAdapter, type AuthRegistrationErrorCode, type AuthResult, type AuthRuntimeBindings, type AuthUser, type CurrentAuthResponse, type getAuthRuntime, type HoloAuthUser, type register, type user } from '../src' +import auth, { AuthError, isAuthError, type AuthEmailVerificationConsumeErrorCode, type AuthEmailVerificationResendErrorCode, type AuthErrorCode, type AuthEstablishedSession, type AuthFailure, type AuthFieldErrors, type AuthGuardFacade, type AuthImpersonationState, type AuthLoginErrorCode, type AuthLogoutResult, type AuthPasswordResetConsumeErrorCode, type AuthPasswordResetRequestErrorCode, type AuthProviderAdapter, type AuthRegistrationErrorCode, type AuthResult, type AuthRuntimeBindings, type AuthUser, type CurrentAuthResponse, type EmailVerificationTokenResult, type getAuthRuntime, type HoloAuthUser, type register, type user, type verifyEmail } from '../src' import clientAuth, { type refreshUser as refreshClientUser, type useAuth as clientUseAuth, type user as clientUser } from '../src/client' import type { useAuth as useNextAuth } from '../src/next/client' import type { useAuth as useNuxtAuth } from '../src/nuxt' @@ -118,6 +118,12 @@ describe('@holo-js/auth typing', () => { expectTypeOf(auth.impersonation).returns.toEqualTypeOf>() expectTypeOf(auth.stopImpersonating).returns.toEqualTypeOf>() expectTypeOf(auth.logout).returns.toEqualTypeOf>() + expectTypeOf(auth.verifyEmail).parameter(0).toEqualTypeOf() + expectTypeOf(auth.verifyEmail).returns.toEqualTypeOf>>>() + expectTypeOf(auth.sendEmailVerification).parameter(0).toEqualTypeOf() + expectTypeOf(auth.sendEmailVerification).returns.toEqualTypeOf>>>() + expectTypeOf(auth.resendEmailVerification).parameter(0).toEqualTypeOf() + expectTypeOf(auth.resendEmailVerification).returns.toEqualTypeOf>>>() expectTypeOf(clientAuth.user).returns.toEqualTypeOf>() expectTypeOf(clientAuth.useAuth).returns.toEqualTypeOf boolean @@ -150,6 +156,9 @@ describe('@holo-js/auth typing', () => { passwordConfirmation?: readonly string[] }> >() + expectTypeOf>>().toEqualTypeOf< + AuthResult> + >() }) it('keeps legacy custom session runtimes assignable to auth runtime bindings', () => { diff --git a/packages/auth/tests/docs-smoke.test.ts b/packages/auth/tests/docs-smoke.test.ts index 2df9889..1979cda 100644 --- a/packages/auth/tests/docs-smoke.test.ts +++ b/packages/auth/tests/docs-smoke.test.ts @@ -92,8 +92,9 @@ describe('auth documentation smoke checks', () => { expect(verification).toContain('APP_URL') expect(verification).toContain('emailVerificationRequired') expect(verification).toContain('emailVerificationRoute') - expect(verification).toContain('verification.consume') - expect(verification).toContain('verification.resend') + expect(verification).toContain('verifyEmail') + expect(verification).toContain('resendEmailVerification') + expect(verification).toContain('sendEmailVerification') expect(verification).toContain('@holo-js/notifications') expect(verification).toContain('@holo-js/mail') expect(verification).not.toContain('notify(created, verificationCreated(token))') diff --git a/packages/auth/tests/package.test.ts b/packages/auth/tests/package.test.ts index 102410e..2393aa7 100644 --- a/packages/auth/tests/package.test.ts +++ b/packages/auth/tests/package.test.ts @@ -24,12 +24,15 @@ import auth, { refreshUser, register, requestPasswordReset, + resendEmailVerification, resetPassword, resetAuthRuntime, + sendEmailVerification, stopImpersonating, tokens, user, verification, + verifyEmail, verifyPassword, } from '../src' import clientAuth, { @@ -1644,17 +1647,17 @@ describe('@holo-js/auth package runtime', () => { expect(runtime.deliveries[0]?.tokenValue).toBe(token.plainTextToken) const invalidVerificationError = expectAuthFailureCode( - await verification.consume('bad-token'), + await verifyEmail('bad-token'), 'email_verification_token_invalid', ) expect(invalidVerificationError.message).toContain('Invalid email verification token') expectAuthFailureCode( - await verification.consume(`${token.id}.wrong-secret`), + await verifyEmail(`${token.id}.wrong-secret`), 'email_verification_token_expired', ) - const verified = unwrapAuthResult(await verification.consume(token.plainTextToken)) + const verified = unwrapAuthResult(await verifyEmail(token.plainTextToken)) expect(verified).toMatchObject({ id: 1, email: 'ava@example.com', @@ -1662,12 +1665,12 @@ describe('@holo-js/auth package runtime', () => { expect(runtime.usersProvider.users.get(1)?.email_verified_at).toBeInstanceOf(Date) expect(runtime.emailVerificationTokenStore.records.size).toBe(0) - expectAuthFailureCode(await verification.consume(token.plainTextToken), 'email_verification_token_expired') + expectAuthFailureCode(await verifyEmail(token.plainTextToken), 'email_verification_token_expired') const expired = await verification.create(created, { expiresAt: new Date('2026-04-07T00:00:00.000Z'), }) - expectAuthFailureCode(await verification.consume(expired.plainTextToken), 'email_verification_token_expired') + expectAuthFailureCode(await verifyEmail(expired.plainTextToken), 'email_verification_token_expired') }) it('resends verification tokens by email without requiring an active session', async () => { @@ -1684,26 +1687,28 @@ describe('@holo-js/auth package runtime', () => { expect(runtime.deliveries).toHaveLength(1) - const resent = unwrapAuthResult(await verification.resend({ + const sent = unwrapAuthResult(await getAuthRuntime().sendEmailVerification('ava@example.com')) + const resent = unwrapAuthResult(await getAuthRuntime().resendEmailVerification('ava@example.com')) + const topLevelSent = unwrapAuthResult(await sendEmailVerification('ava@example.com')) + const legacyResent = unwrapAuthResult(await verification.resend({ email: 'ava@example.com', })) + expect(sent.plainTextToken).toContain('.') expect(resent.plainTextToken).toContain('.') - expect(runtime.deliveries).toHaveLength(2) - expect(runtime.deliveries[1]).toMatchObject({ + expect(topLevelSent.plainTextToken).toContain('.') + expect(legacyResent.plainTextToken).toContain('.') + expect(runtime.deliveries).toHaveLength(5) + expect(runtime.deliveries[4]).toMatchObject({ type: 'verification', email: 'ava@example.com', - tokenId: resent.id, - tokenValue: resent.plainTextToken, + tokenId: legacyResent.id, + tokenValue: legacyResent.plainTextToken, }) - expectAuthFailureCode(await verification.resend({ - email: 'missing@example.com', - }), 'email_verification_user_missing') + expectAuthFailureCode(await resendEmailVerification('missing@example.com'), 'email_verification_user_missing') - await verification.consume(resent.plainTextToken) - expectAuthFailureCode(await verification.resend({ - email: 'ava@example.com', - }), 'email_already_verified') + await verifyEmail(legacyResent.plainTextToken) + expectAuthFailureCode(await resendEmailVerification('ava@example.com'), 'email_already_verified') }) it('fails verification and password reset flows when a provider cannot persist user changes', async () => { diff --git a/packages/core/src/portable/holo.ts b/packages/core/src/portable/holo.ts index fbaeedc..a008309 100644 --- a/packages/core/src/portable/holo.ts +++ b/packages/core/src/portable/holo.ts @@ -299,6 +299,9 @@ export interface HoloAuthRuntimeBinding { resend(options?: { readonly guard?: string, readonly expiresAt?: Date, readonly email?: string }): Promise> consume(plainTextToken: string): Promise> } + verifyEmail(token: string): Promise> + sendEmailVerification(email?: string, options?: { readonly guard?: string, readonly expiresAt?: Date }): Promise> + resendEmailVerification(email?: string, options?: { readonly guard?: string, readonly expiresAt?: Date }): Promise> requestPasswordReset( input: { readonly email: string