diff --git a/packages/adapter-nuxt/tests/module.test.ts b/packages/adapter-nuxt/tests/module.test.ts index 41eb127..eccc9fb 100644 --- a/packages/adapter-nuxt/tests/module.test.ts +++ b/packages/adapter-nuxt/tests/module.test.ts @@ -337,9 +337,9 @@ export default defineDatabaseConfig({ expect(addImports).toHaveBeenCalledTimes(1) expect(addImports.mock.calls[0]?.[0]).toHaveLength(6) expect(addImports.mock.calls[0]?.[0]).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: 'holo', as: 'holo', from: './runtime/composables' }), - expect.objectContaining({ name: 'useStorage', as: 'useStorage', from: './runtime/composables/storage' }), - expect.objectContaining({ name: 'Storage', as: 'Storage', from: './runtime/composables/storage' }), + expect.objectContaining({ name: 'holo', as: 'holo', from: '@holo-js/adapter-nuxt/runtime' }), + expect.objectContaining({ name: 'useStorage', as: 'useStorage', from: '@holo-js/adapter-nuxt/storage' }), + expect.objectContaining({ name: 'Storage', as: 'Storage', from: '@holo-js/adapter-nuxt/storage' }), ])) expect(addServerImportsDir).toHaveBeenCalledWith('./runtime/server/imports') expect(addServerImportsDir).toHaveBeenCalledTimes(1) diff --git a/packages/adapter-nuxt/tests/setup.test.ts b/packages/adapter-nuxt/tests/setup.test.ts index 3e13d4c..20781ff 100644 --- a/packages/adapter-nuxt/tests/setup.test.ts +++ b/packages/adapter-nuxt/tests/setup.test.ts @@ -423,9 +423,9 @@ export default defineStorageConfig({ expect(addImports).toHaveBeenCalledTimes(1) expect(addImports.mock.calls[0]?.[0]).toHaveLength(6) expect(addImports.mock.calls[0]?.[0]).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: 'holo', as: 'holo', from: './runtime/composables' }), - expect.objectContaining({ name: 'useStorage', as: 'useStorage', from: './runtime/composables/storage' }), - expect.objectContaining({ name: 'Storage', as: 'Storage', from: './runtime/composables/storage' }), + expect.objectContaining({ name: 'holo', as: 'holo', from: '@holo-js/adapter-nuxt/runtime' }), + expect.objectContaining({ name: 'useStorage', as: 'useStorage', from: '@holo-js/adapter-nuxt/storage' }), + expect.objectContaining({ name: 'Storage', as: 'Storage', from: '@holo-js/adapter-nuxt/storage' }), ])) expect(addServerImportsDir).toHaveBeenCalledWith('./runtime/server/imports') expect(addServerImportsDir).toHaveBeenCalledTimes(1) diff --git a/packages/adapter-sveltekit/src/index.ts b/packages/adapter-sveltekit/src/index.ts index 62aa103..e556fdc 100644 --- a/packages/adapter-sveltekit/src/index.ts +++ b/packages/adapter-sveltekit/src/index.ts @@ -1,9 +1,9 @@ +import { AsyncLocalStorage } from 'node:async_hooks' import { createHoloFrameworkAdapter, type HoloAdapterProject, type HoloFrameworkOptions, } from '@holo-js/core' -import { getRequestEvent } from '$app/server' import type { HoloConfigMap } from '@holo-js/config' export { holoSvelteKitTransport, @@ -14,23 +14,12 @@ export type SvelteKitHoloOptions = HoloFrameworkOptions export type SvelteKitHoloProject = HoloAdapterProject -function withSvelteKitAuthRequest(options: SvelteKitHoloOptions = {}): SvelteKitHoloOptions { - if (options.authRequest) { - return options +type SvelteKitRequestEvent = { + readonly cookies: { + get(name: string): string | undefined } - - return { - ...options, - authRequest: { - async getCookie(name: string) { - const event = getRequestEvent() - return event.cookies.get(name) ?? undefined - }, - async getHeader(name: string) { - const event = getRequestEvent() - return event.request.headers.get(name) ?? undefined - }, - }, + readonly request: { + readonly headers: Headers } } @@ -38,25 +27,53 @@ const svelteKitAdapter = createHoloFrameworkAdapter({ stateKey: '__holoSvelteKitAdapter__', displayName: 'SvelteKit', }) +const svelteKitRequestEventStore = new AsyncLocalStorage() + +function resolveSvelteKitAuthRequestAccessors(): NonNullable { + return { + async getCookie(name: string) { + const event = svelteKitRequestEventStore.getStore() + return event?.cookies.get(name) ?? undefined + }, + async getHeader(name: string) { + const event = svelteKitRequestEventStore.getStore() + return event?.request.headers.get(name) ?? undefined + }, + } +} + +function resolveSvelteKitOptions(options: SvelteKitHoloOptions): SvelteKitHoloOptions { + return { + ...options, + authRequest: options.authRequest ?? resolveSvelteKitAuthRequestAccessors(), + } +} export const svelteKitHoloCapabilities = svelteKitAdapter.capabilities +export function runWithSvelteKitRequestEvent( + event: SvelteKitRequestEvent, + callback: () => TValue, +): TValue { + return svelteKitRequestEventStore.run(event, callback) +} + export async function createSvelteKitHoloProject( options: SvelteKitHoloOptions = {}, ): Promise> { - return svelteKitAdapter.createProject(withSvelteKitAuthRequest(options)) + return svelteKitAdapter.createProject(resolveSvelteKitOptions(options)) } export async function initializeSvelteKitHoloProject( options: SvelteKitHoloOptions = {}, ): Promise> { - return svelteKitAdapter.initializeProject(withSvelteKitAuthRequest(options)) + return svelteKitAdapter.initializeProject(resolveSvelteKitOptions(options)) } export function createSvelteKitHoloHelpers( options: SvelteKitHoloOptions = {}, ) { - return svelteKitAdapter.createHelpers(withSvelteKitAuthRequest(options)) + return svelteKitAdapter.createHelpers(resolveSvelteKitOptions(options)) } export async function resetSvelteKitHoloProject(): Promise { diff --git a/packages/adapter-sveltekit/src/sveltekit.d.ts b/packages/adapter-sveltekit/src/sveltekit.d.ts deleted file mode 100644 index 4d79019..0000000 --- a/packages/adapter-sveltekit/src/sveltekit.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -declare module '$app/server' { - export function getRequestEvent(): { - readonly cookies: { - get(name: string): string | undefined - } - readonly request: { - readonly headers: Headers - } - } -} diff --git a/packages/adapter-sveltekit/tests/adapter.test.ts b/packages/adapter-sveltekit/tests/adapter.test.ts index 88db61a..2840ab5 100644 --- a/packages/adapter-sveltekit/tests/adapter.test.ts +++ b/packages/adapter-sveltekit/tests/adapter.test.ts @@ -121,20 +121,6 @@ async function loadAdapterModule() { if (!adapterModulePromise) { const { coreEntryUrl } = await ensureIsolatedCoreBuild() vi.doMock('@holo-js/core', () => import(coreEntryUrl)) - vi.doMock('$app/server', () => ({ - getRequestEvent() { - return { - cookies: { - get() { - return undefined - }, - }, - request: { - headers: new Headers(), - }, - } - }, - })) adapterModulePromise = import('../src') } @@ -187,7 +173,6 @@ afterEach(async () => { await resetSvelteKitHoloProject() adapterModulePromise = null vi.doUnmock('@holo-js/core') - vi.doUnmock('$app/server') vi.resetModules() await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) }, 45000) diff --git a/packages/adapter-sveltekit/tests/adapter.type.test.ts b/packages/adapter-sveltekit/tests/adapter.type.test.ts index 69fc9b4..7819153 100644 --- a/packages/adapter-sveltekit/tests/adapter.type.test.ts +++ b/packages/adapter-sveltekit/tests/adapter.type.test.ts @@ -2,8 +2,7 @@ import { execFileSync } from 'node:child_process' import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { createSvelteKitHoloHelpers } from '../src' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { SerializedSvelteKitData } from '../src/transport' import { linkInstalledDependenciesForPackage, @@ -33,8 +32,13 @@ declare module '@holo-js/config' { } } +afterEach(() => { + vi.resetModules() +}) + describe('@holo-js/adapter-sveltekit typing', () => { - it('preserves inference for helper accessors', () => { + it('preserves inference for helper accessors', async () => { + const { createSvelteKitHoloHelpers } = await import('../src') const helpers = createSvelteKitHoloHelpers() type Helpers = typeof helpers diff --git a/packages/adapter-sveltekit/tests/runtime.test.ts b/packages/adapter-sveltekit/tests/runtime.test.ts new file mode 100644 index 0000000..0258092 --- /dev/null +++ b/packages/adapter-sveltekit/tests/runtime.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +type MockAuthRequest = { + getCookie(name: string): Promise + getHeader(name: string): Promise +} + +function makeHoloCoreMock( + setCapturedAuthRequest: (authRequest: MockAuthRequest | undefined) => void, +) { + return { + createHoloFrameworkAdapter: () => ({ + capabilities: {}, + async createProject() { + return {} + }, + async initializeProject() { + return {} + }, + createHelpers(options: { + authRequest?: MockAuthRequest + }) { + setCapturedAuthRequest(options.authRequest) + + return { + async getApp() { + return {} + }, + async getProject() { + return {} + }, + async getSession() { + return undefined + }, + async getAuth() { + return undefined + }, + async useConfig() { + return undefined + }, + async config() { + return undefined + }, + } + }, + async resetProject() {}, + internals: { + getState() { + return {} + }, + resolveOptions() { + return {} + }, + }, + }), + } +} + +afterEach(() => { + vi.doUnmock('@holo-js/core') + vi.resetModules() +}) + +describe('@holo-js/adapter-sveltekit request context', () => { + it('owns auth request accessors inside the adapter and resolves them from the current request event', async () => { + let capturedAuthRequest: MockAuthRequest | undefined + + vi.doMock('@holo-js/core', () => makeHoloCoreMock((authRequest) => { + capturedAuthRequest = authRequest + })) + + const { createSvelteKitHoloHelpers, runWithSvelteKitRequestEvent } = await import('../src') + const helpers = createSvelteKitHoloHelpers({ + projectRoot: '/tmp/holo-sveltekit-runtime', + }) + + await runWithSvelteKitRequestEvent({ + cookies: { + get(name: string) { + return name === 'session' ? 'cookie-value' : undefined + }, + }, + request: { + headers: new Headers({ + 'x-request-id': 'header-value', + }), + }, + }, async () => { + await helpers.getProject() + + expect(capturedAuthRequest).toBeDefined() + if (!capturedAuthRequest) { + throw new Error('Expected auth request accessors to be captured.') + } + await expect(capturedAuthRequest.getCookie('session')).resolves.toBe('cookie-value') + await expect(capturedAuthRequest.getHeader('x-request-id')).resolves.toBe('header-value') + }) + + expect(capturedAuthRequest).toBeDefined() + if (!capturedAuthRequest) { + throw new Error('Expected auth request accessors to be captured.') + } + await expect(capturedAuthRequest.getCookie('session')).resolves.toBeUndefined() + await expect(capturedAuthRequest.getHeader('x-request-id')).resolves.toBeUndefined() + }) + + it('preserves explicit auth request overrides', async () => { + let capturedAuthRequest: MockAuthRequest | undefined + + vi.doMock('@holo-js/core', () => makeHoloCoreMock((authRequest) => { + capturedAuthRequest = authRequest + })) + + const customAuthRequest = { + async getCookie() { + return 'custom-cookie' + }, + async getHeader() { + return 'custom-header' + }, + } + + const { createSvelteKitHoloHelpers } = await import('../src') + const helpers = createSvelteKitHoloHelpers({ + projectRoot: '/tmp/holo-sveltekit-runtime', + authRequest: customAuthRequest, + }) + + await helpers.getProject() + + expect(capturedAuthRequest).toBe(customAuthRequest) + }) +}) diff --git a/packages/auth/src/contracts.ts b/packages/auth/src/contracts.ts index cdfdd29..3dd040d 100644 --- a/packages/auth/src/contracts.ts +++ b/packages/auth/src/contracts.ts @@ -244,6 +244,7 @@ type AuthProviderAdapterBase = { findById(id: string | number): Promise findByCredentials(credentials: Readonly>): Promise create(input: Readonly>): Promise + delete?(id: string | number): Promise update?(user: TUser, input: Readonly>): Promise matchesUser?(user: unknown): boolean getId(user: TUser): string | number @@ -438,6 +439,10 @@ export interface AuthSessionRuntime { sessionId: string, options?: { readonly store?: string }, ): Promise + consumeRememberMeToken?( + token: string, + options?: { readonly store?: string }, + ): Promise cookie?( name: string, value: string, diff --git a/packages/auth/src/runtime.ts b/packages/auth/src/runtime.ts index 026cc37..9a412fa 100644 --- a/packages/auth/src/runtime.ts +++ b/packages/auth/src/runtime.ts @@ -62,6 +62,7 @@ type ErasedAuthProviderAdapter = { findById(id: string | number): Promise findByCredentials(credentials: Readonly>): Promise create(input: Readonly>): Promise + delete?(id: string | number): Promise update?(user: unknown, input: Readonly>): Promise matchesUser?(user: unknown): boolean getId(user: unknown): string | number @@ -936,6 +937,19 @@ async function hydrateGuardContextFromRequest(guardName: string): Promise const rememberToken = await resolveRequestCookie(bindings, rememberCookie.name) if (rememberToken) { bindings.context.setRememberToken?.(guardName, rememberToken) + if (!bindings.context.getSessionId(guardName)) { + const rememberedSession = await bindings.session.consumeRememberMeToken?.(rememberToken) + const payload = readSessionPayload(rememberedSession, guardName) + if (rememberedSession && payload?.guard === guardName) { + bindings.context.setSessionId(guardName, rememberedSession.id) + bindings.context.setCachedUser( + guardName, + rehydrateSerializedUser(payload.user, payload.provider), + ) + } else if (!rememberedSession) { + bindings.context.setRememberToken?.(guardName) + } + } } } } @@ -2215,14 +2229,50 @@ async function registerDefaultUser(input: AuthRegistrationInput): Promise undefined) + await rollbackRegisteredUser(adapter, user, serialized).catch(() => undefined) + throw error + } } return serialized } +async function rollbackRegisteredUser( + adapter: ErasedAuthProviderAdapter, + createdUser: unknown, + serialized: SerializedAuthUser, +): Promise { + if (createdUser && typeof createdUser === 'object' && 'delete' in createdUser && typeof createdUser.delete === 'function') { + try { + await createdUser.delete() + return + } catch (deleteError) { + if (adapter.delete) { + try { + await adapter.delete(serialized.id) + } catch (adapterDeleteError) { + throw new AggregateError( + [deleteError, adapterDeleteError], + 'Failed to rollback the registered user.', + ) + } + } + + throw deleteError + } + } + + if (adapter.delete) { + await adapter.delete(serialized.id) + } +} + function findProviderNameForUser(user: unknown): string { const bindings = getRuntimeBindings() const providerNames = Object.keys(bindings.providers) diff --git a/packages/auth/tests/contracts.type.test.ts b/packages/auth/tests/contracts.type.test.ts index 01cfe91..cfb5e81 100644 --- a/packages/auth/tests/contracts.type.test.ts +++ b/packages/auth/tests/contracts.type.test.ts @@ -96,6 +96,7 @@ describe('@holo-js/auth typing', () => { } expectTypeOf(adapter.serialize).returns.toEqualTypeOf() + expectTypeOf(adapter.delete).toEqualTypeOf<((id: string | number) => Promise) | undefined>() expectTypeOf(auth.user).returns.toEqualTypeOf>() expectTypeOf(auth.login).returns.toEqualTypeOf>>() expectTypeOf(auth.loginUsing).returns.toEqualTypeOf>() diff --git a/packages/auth/tests/package.test.ts b/packages/auth/tests/package.test.ts index ccfe408..8fcfbf1 100644 --- a/packages/auth/tests/package.test.ts +++ b/packages/auth/tests/package.test.ts @@ -176,6 +176,20 @@ class InMemoryProviderAdapter implements AuthProviderAdapter { return record } + async delete(id: string | number): Promise { + const numericId = typeof id === 'number' ? id : Number.parseInt(String(id), 10) + const existing = this.users.get(numericId) + if (!existing) { + return + } + + this.users.delete(numericId) + this.usersByEmail.delete(existing.email) + if (existing.phone) { + this.usersByPhone.delete(existing.phone) + } + } + async update(user: UserRecord, input: Readonly>): Promise { const currentEmail = user.email if (typeof input.name === 'string') { @@ -431,6 +445,7 @@ function configureRuntime(options: { emailVerificationRequired?: boolean adminProvider?: InMemoryProviderAdapter authConfig?: HoloAuthConfig + delivery?: Partial passwordHasher?: NonNullable[0]>['passwordHasher'] } = {}) { const sessionStore = new InMemorySessionStore() @@ -455,6 +470,7 @@ function configureRuntime(options: { tokenValue: input.token.plainTextToken, }) }, + ...options.delivery, } configureSessionRuntime({ config: { @@ -1191,6 +1207,64 @@ describe('@holo-js/auth package runtime', () => { }), 'password_confirmation_mismatch') }) + it('rolls back registration when email verification creation fails', async () => { + const runtime = configureRuntime({ + emailVerificationRequired: true, + delivery: { + async sendEmailVerification() { + throw new Error('delivery failed') + }, + }, + }) + + await expect(register({ + name: 'Ava', + email: 'ava@example.com', + password: 'secret-secret', + passwordConfirmation: 'secret-secret', + })).rejects.toThrow('delivery failed') + + expect(runtime.usersProvider.users.size).toBe(0) + expect(runtime.usersProvider.usersByEmail.size).toBe(0) + expect(runtime.emailVerificationTokenStore.records.size).toBe(0) + }) + + it('falls back to adapter deletion when model deletion throws during registration rollback', async () => { + const runtime = configureRuntime({ + emailVerificationRequired: true, + delivery: { + async sendEmailVerification() { + throw new Error('delivery failed') + }, + }, + }) + const adapterDelete = vi.spyOn(runtime.usersProvider, 'delete') + const originalCreate = runtime.usersProvider.create.bind(runtime.usersProvider) + + runtime.usersProvider.create = vi.fn(async (input) => { + const created = await originalCreate(input) + + return { + ...created, + async delete() { + throw new Error('model delete failed') + }, + } + }) + + await expect(register({ + name: 'Ava', + email: 'ava@example.com', + password: 'secret-secret', + passwordConfirmation: 'secret-secret', + })).rejects.toThrow('delivery failed') + + expect(adapterDelete).toHaveBeenCalledWith(1) + expect(runtime.usersProvider.users.size).toBe(0) + expect(runtime.usersProvider.usersByEmail.size).toBe(0) + expect(runtime.emailVerificationTokenStore.records.size).toBe(0) + }) + it('accepts non-email credentials when the application passes validated input', async () => { const runtime = configureRuntime({ authConfig: defineAuthConfig({ @@ -2905,6 +2979,76 @@ describe('@holo-js/auth package runtime', () => { }) }) + it('restores a session from the remember cookie when no session cookie is present', async () => { + const runtime = configureRuntime() + const created = await runtime.usersProvider.create({ + name: 'Ava', + email: 'ava@example.com', + password: null, + email_verified_at: new Date(), + }) + const remembered = await auth.guard('web').loginUsing(created, { + remember: true, + }) + const restartedContext = Object.freeze({ + ...authRuntimeInternals.createMemoryAuthContext(), + getRequestCookie(name: string) { + if (name === 'holo_session_remember') { + return remembered.rememberToken + } + + return undefined + }, + }) + + configureAuthRuntime({ + config: defineAuthConfig({ + defaults: { + guard: 'web', + passwords: 'users', + }, + guards: { + web: { + driver: 'session', + provider: 'users', + }, + admin: { + driver: 'session', + provider: 'admins', + }, + api: { + driver: 'token', + provider: 'users', + }, + }, + providers: { + users: { + model: 'User', + }, + admins: { + model: 'Admin', + }, + }, + }), + session: getSessionRuntime(), + providers: { + users: runtime.usersProvider, + admins: runtime.adminsProvider, + }, + tokens: runtime.tokenStore, + emailVerificationTokens: runtime.emailVerificationTokenStore, + passwordResetTokens: runtime.passwordResetTokenStore, + context: restartedContext, + }) + + await expect(auth.guard('web').user()).resolves.toMatchObject({ + id: created.id, + email: created.email, + }) + expect(restartedContext.getSessionId('web')).toBe(remembered.sessionId) + expect(restartedContext.getRememberToken?.('web')).toBe(remembered.rememberToken) + }) + it('clears hosted provider cookies without inheriting custom app session scope', async () => { const runtime = configureRuntime({ authConfig: { diff --git a/packages/cli/src/project/registry-svelte.ts b/packages/cli/src/project/registry-svelte.ts index 2a41549..daed136 100644 --- a/packages/cli/src/project/registry-svelte.ts +++ b/packages/cli/src/project/registry-svelte.ts @@ -31,9 +31,8 @@ function renderManagedSvelteServerHooksModule(): string { return [ '// Generated by holo prepare. Do not edit.', '', - 'import \'@holo-js/adapter-sveltekit\'', - '', 'import type { Handle, HandleFetch, HandleServerError } from \'@sveltejs/kit\'', + 'import { runWithSvelteKitRequestEvent } from \'@holo-js/adapter-sveltekit\'', 'import { sequence } from \'@sveltejs/kit/hooks\'', 'import { holo } from \'$lib/server/holo\'', 'import * as userHooks from \'../../src/hooks.server\'', @@ -53,7 +52,7 @@ function renderManagedSvelteServerHooksModule(): string { ' return `/${raw.replace(/^\\/+|\\/+$/g, \'\')}`', '}', '', - 'const holoHandle: Handle = async ({ event, resolve }) => {', + 'const holoHandle: Handle = ({ event, resolve }) => runWithSvelteKitRequestEvent(event, async () => {', ' await holo.getApp()', '', ' const storageRoutePrefix = normalizeStorageRoutePrefix(process.env.STORAGE_ROUTE_PREFIX)', @@ -67,7 +66,7 @@ function renderManagedSvelteServerHooksModule(): string { ' }', '', ' return resolve(event)', - '}', + '})', '', 'export const handle = typeof serverHooks.handle === \'function\'', ' ? sequence(holoHandle, serverHooks.handle)', diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index cdec630..261b29e 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -1284,6 +1284,13 @@ export default { packageManager: 'bun', storageDefaultDisk: 'local', }).find(file => file.path === 'src/hooks.server.ts')?.contents).toContain('export {}') + expect(projectInternals.renderFrameworkFiles({ + projectName: 'Svelte App', + framework: 'sveltekit', + databaseDriver: 'sqlite', + packageManager: 'bun', + storageDefaultDisk: 'local', + }).find(file => file.path === 'src/lib/server/holo.ts')?.contents).toContain('@holo-js/adapter-sveltekit') expect(projectInternals.renderScaffoldPackageJson({ projectName: 'Svelte App', framework: 'sveltekit', diff --git a/packages/core/src/portable/holo.ts b/packages/core/src/portable/holo.ts index 5f24042..b34485c 100644 --- a/packages/core/src/portable/holo.ts +++ b/packages/core/src/portable/holo.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from 'node:async_hooks' import { existsSync } from 'node:fs' import { createHash, createHmac } from 'node:crypto' import { createRequire } from 'node:module' @@ -1384,6 +1385,57 @@ function attachAuthRequestAccessors( + context: TContext, + accessors?: CreateHoloOptions['authRequest'], +): TContext & { + getRequestCookie?(name: string): string | undefined | Promise + getRequestHeader?(name: string): string | undefined | Promise + setRequestAccessors(accessors?: CreateHoloOptions['authRequest']): void +} { + const requestAccessorStorage = new AsyncLocalStorage<{ + readonly accessors?: CreateHoloOptions['authRequest'] + }>() + type RequestAccessContext = TContext & { + getRequestCookie?(name: string): string | undefined | Promise + getRequestHeader?(name: string): string | undefined | Promise + } + + const resolveRequestContext = (): RequestAccessContext => { + const requestAccessors = requestAccessorStorage.getStore() + const resolvedAccessors = requestAccessors ? requestAccessors.accessors : accessors + + return resolvedAccessors + ? attachAuthRequestAccessors(context, resolvedAccessors) + : context as RequestAccessContext + } + + return Object.freeze({ + ...context, + getRequestCookie(name) { + return resolveRequestContext().getRequestCookie?.(name) + }, + getRequestHeader(name) { + return resolveRequestContext().getRequestHeader?.(name) + }, + setRequestAccessors(nextAccessors) { + requestAccessorStorage.enterWith({ + accessors: nextAccessors, + }) + }, + }) +} + async function loadQueueModule(required = false): Promise { const queueModule = await portableRuntimeModuleInternals.importOptionalModule('@holo-js/queue') /* v8 ignore next 3 -- exercised only when the optional package is absent outside the monorepo test graph */ @@ -3215,6 +3267,7 @@ async function createCoreAuthProviders( type AuthModelRepository = { saveEntity?(entity: unknown, internalColumns?: ReadonlySet): Promise + delete?(id: unknown): Promise } const resolvedModule = await resolveAuthProviderRuntime(projectRoot, loadedConfig, providerConfig.model) as { @@ -3241,6 +3294,7 @@ async function createCoreAuthProviders( getRepository?(): AuthModelRepository create(values: Record): Promise update(id: unknown, values: Record): Promise + delete?(id: unknown): Promise } const throwPendingSchema = (): never => { throw new Error( @@ -3413,6 +3467,27 @@ async function createCoreAuthProviders( return markProviderUser(persisted ?? await model.create(values), providerName) }, + async delete(id: string | number) { + const repository = typeof model.getRepository === 'function' + ? model.getRepository() + : null + if (repository && typeof repository.delete === 'function') { + await repository.delete(id) + return + } + + if (typeof model.delete === 'function') { + await model.delete(id) + return + } + + const existing = typeof model.find === 'function' + ? await model.find(id) + : null + if (existing && typeof existing === 'object' && 'delete' in existing && typeof existing.delete === 'function') { + await existing.delete() + } + }, /* v8 ignore start -- adapter shape mirrors the auth package contract; core tests cover the wired runtime behavior */ async update(user: unknown, input: Readonly>) { const id = getEntityAttributes(user).id @@ -3801,6 +3876,7 @@ export async function reconfigureOptionalHoloSubsystems { const cacheConfigured = hasLoadedConfigFile(loadedConfig, 'cache') @@ -4031,7 +4107,9 @@ export async function reconfigureOptionalHoloSubsystems | undefined + let authContext: ReturnType & { + setRequestAccessors?(accessors?: CreateHoloOptions['authRequest']): void + } | undefined const workosModule = authConfigUsesWorkosProviders(loadedConfig) ? await loadWorkosModule(true) : undefined @@ -4084,9 +4162,7 @@ export async function reconfigureOptionalHoloSubsystems( let activeAuthorizationModule: AuthorizationModule | undefined let activeSessionRuntime: HoloSessionRuntimeBinding | undefined let activeAuthRuntime: HoloAuthRuntimeBinding | undefined - let activeAuthContext: { activate(): void } | undefined + let activeAuthContext: { + activate(): void + setRequestAccessors?(accessors?: CreateHoloOptions['authRequest']): void + } | undefined let previousOptionalSubsystemBindings: OptionalSubsystemRuntimeBindings | undefined const previousRenderView = options.renderView ? getRuntimeState().renderView @@ -4237,7 +4316,9 @@ export async function createHolo( drivers: new Map(), }) as HoloQueueRuntimeBinding - const runtime: MutableHoloRuntime = { + const runtime: MutableHoloRuntime & { + setAuthRequestAccessors(accessors?: CreateHoloOptions['authRequest']): void + } = { projectRoot, loadedConfig, registry, @@ -4257,6 +4338,9 @@ export async function createHolo( initialized: false, useConfig: accessors.useConfig, config: accessors.config, + setAuthRequestAccessors(authRequest) { + activeAuthContext?.setRequestAccessors?.(authRequest) + }, async initialize() { if (runtime.initialized) { throw new Error('Holo runtime is already initialized.') @@ -4418,6 +4502,9 @@ export async function initializeHolo & { + setAuthRequestAccessors?(accessors?: CreateHoloOptions['authRequest']): void + }).setAuthRequestAccessors?.(options.authRequest) return current } @@ -4426,7 +4513,12 @@ export async function initializeHolo> + return (state.pending as Promise>).then((runtime) => { + ;(runtime as HoloRuntime & { + setAuthRequestAccessors?(accessors?: CreateHoloOptions['authRequest']): void + }).setAuthRequestAccessors?.(options.authRequest) + return runtime + }) } const pending = (async () => { diff --git a/packages/core/tests/auth-runtime.test.ts b/packages/core/tests/auth-runtime.test.ts index 0d811f3..e63324c 100644 --- a/packages/core/tests/auth-runtime.test.ts +++ b/packages/core/tests/auth-runtime.test.ts @@ -6,7 +6,7 @@ import { createSchemaService, DB } from '@holo-js/db' import { authRuntimeInternals } from '../../auth/src' import { listFakeSentMails, resetFakeSentMails } from '@holo-js/mail' import { configureNotificationsRuntime } from '@holo-js/notifications' -import { createHolo, holoRuntimeInternals, resetHoloRuntime } from '../src' +import { createHolo, holoRuntimeInternals, initializeHolo, resetHoloRuntime } from '../src' const configEntry = JSON.stringify(resolve(import.meta.dirname, '../../config/src/index.ts')) const tempDirs: string[] = [] @@ -1217,6 +1217,86 @@ export default { await runtime.shutdown() }) + it('refreshes auth request accessors when initializeHolo reuses the current runtime', async () => { + const root = await createProject({ + auth: true, + }) + + await initializeHolo(root, { + authRequest: { + getCookie(name) { + return `${name}-first` + }, + }, + processEnv: process.env, + preferCache: false, + }) + + expect(await authRuntimeInternals.getRuntimeBindings().context.getRequestCookie?.('session')).toBe('session-first') + + await initializeHolo(root, { + authRequest: { + getCookie(name) { + return `${name}-second` + }, + }, + processEnv: process.env, + preferCache: false, + }) + + expect(await authRuntimeInternals.getRuntimeBindings().context.getRequestCookie?.('session')).toBe('session-second') + }) + + it('keeps auth request accessors isolated per async request when initializeHolo reuses the current runtime', async () => { + const root = await createProject({ + auth: true, + }) + + await initializeHolo(root, { + authRequest: { + getCookie(name) { + return `${name}-default` + }, + }, + processEnv: process.env, + preferCache: false, + }) + + const [firstCookie, secondCookie] = await Promise.all([ + Promise.resolve().then(async () => { + await initializeHolo(root, { + authRequest: { + getCookie(name) { + return `${name}-first` + }, + }, + processEnv: process.env, + preferCache: false, + }) + + await new Promise(resolvePromise => setTimeout(resolvePromise, 10)) + + return authRuntimeInternals.getRuntimeBindings().context.getRequestCookie?.('session') + }), + Promise.resolve().then(async () => { + await initializeHolo(root, { + authRequest: { + getCookie(name) { + return `${name}-second` + }, + }, + processEnv: process.env, + preferCache: false, + }) + + return authRuntimeInternals.getRuntimeBindings().context.getRequestCookie?.('session') + }), + ]) + + expect(await firstCookie).toBe('session-first') + expect(await secondCookie).toBe('session-second') + }) + it('boots auth with the default file session store when session config is omitted', async () => { const root = await createProject({ auth: true, diff --git a/packages/forms/src/contracts.ts b/packages/forms/src/contracts.ts index d31b627..7778715 100644 --- a/packages/forms/src/contracts.ts +++ b/packages/forms/src/contracts.ts @@ -50,6 +50,11 @@ type RequestLikeHeaders = | Headers | ReadonlyArray | Record + | { + readonly get?: (name: string) => string | null | undefined + readonly forEach?: (callback: (value: string, key: string) => void) => void + readonly entries?: () => Iterable + } export interface FormRequestLikeInput { readonly method?: string @@ -253,8 +258,32 @@ function isHeadersTupleArray(value: unknown): value is ReadonlyArray { + return !!value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype +} + +function isHeaderAccessorObject(value: unknown): value is { + readonly get?: (name: string) => string | null | undefined + readonly forEach?: (callback: (value: string, key: string) => void) => void + readonly entries?: () => Iterable +} { + if (!value || typeof value !== 'object' || isPlainObject(value)) { + return false + } + + const candidate = value as { + readonly get?: unknown + readonly forEach?: unknown + readonly entries?: unknown + } + + return typeof candidate.get === 'function' + || typeof candidate.forEach === 'function' + || typeof candidate.entries === 'function' +} + function isRequestLikeHeaders(value: unknown): value is RequestLikeHeaders { - return value instanceof Headers || isHeadersTupleArray(value) || (!!value && typeof value === 'object') + return value instanceof Headers || isHeadersTupleArray(value) || isHeaderAccessorObject(value) } function normalizeRequestHeaders(input: unknown): Headers { @@ -272,6 +301,26 @@ function normalizeRequestHeaders(input: unknown): Headers { return headers } + if (isHeaderAccessorObject(input)) { + if (typeof input.forEach === 'function') { + input.forEach((value, name) => { + headers.append(name, value) + }) + return headers + } + + if (typeof input.entries === 'function') { + for (const [name, value] of input.entries()) { + headers.append(name, value) + } + return headers + } + + if (typeof input.get === 'function') { + throw new TypeError('get-only header accessor is not iterable.') + } + } + if (input && typeof input === 'object') { for (const [name, value] of Object.entries(input)) { if (typeof value === 'string') { @@ -403,9 +452,10 @@ function isRequestLikeInput(input: unknown): input is FormRequestLikeInput { || typeof candidate.url === 'string' || candidate.url instanceof URL const hasStructuredHeaders = isRequestLikeHeaders(candidate.headers) + const hasPlainHeaderRecord = isPlainObject(candidate.headers) const hasBody = typeof candidate.body !== 'undefined' - return hasRequestMetadata && (hasStructuredHeaders || hasBody) + return hasRequestMetadata && (hasStructuredHeaders || hasPlainHeaderRecord || hasBody) } function normalizeRequestLikeInput(input: FormLikeValidationInput | FormRequestLikeInput | null | undefined): Request | undefined { diff --git a/packages/forms/src/sensitiveInput.ts b/packages/forms/src/sensitiveInput.ts index 2603e3c..1665412 100644 --- a/packages/forms/src/sensitiveInput.ts +++ b/packages/forms/src/sensitiveInput.ts @@ -8,11 +8,6 @@ const DEFAULT_DONT_FLASH_FIELDS = Object.freeze([ 'password', 'password_confirmation', 'passwordConfirmation', - 'token', - 'verification_code', - 'verificationCode', - 'verification_token', - 'verificationToken', ]) const DEFAULT_DONT_FLASH_FIELD_SET = new Set(DEFAULT_DONT_FLASH_FIELDS) diff --git a/packages/forms/tests/contracts.test.ts b/packages/forms/tests/contracts.test.ts index bcdf9e1..dac976d 100644 --- a/packages/forms/tests/contracts.test.ts +++ b/packages/forms/tests/contracts.test.ts @@ -228,7 +228,7 @@ describe('@holo-js/forms contracts', () => { }) }) - it('excludes Laravel-style dontFlash fields from serialized failure payloads', async () => { + it('excludes password-like dontFlash fields while preserving transport tokens in serialized failure payloads', async () => { const registerUser = schema({ email: field.string().required().email(), password: field.string().required().min(8), @@ -259,6 +259,7 @@ describe('@holo-js/forms contracts', () => { submitted: true, values: { email: 'bad', + token: 'reset-token', }, errors: { email: ['Invalid email: Received "bad"'], @@ -270,6 +271,7 @@ describe('@holo-js/forms contracts', () => { valid: false, values: { email: 'bad', + token: 'reset-token', }, errors: { email: ['Invalid email: Received "bad"'], @@ -277,6 +279,21 @@ describe('@holo-js/forms contracts', () => { }) }) + it('preserves verification and reset transport tokens while still stripping passwords', () => { + expect(formsInternals.sanitizeFlashedInput({ + email: 'ava@example.com', + password: 'secret-secret', + token: 'reset-token', + verification_token: 'verify-token', + verificationCode: '123456', + })).toEqual({ + email: 'ava@example.com', + token: 'reset-token', + verification_token: 'verify-token', + verificationCode: '123456', + }) + }) + it('does not coerce plain form objects with request-like field names into Request inputs', async () => { const requestMeta = schema({ method: field.string().required(), @@ -578,6 +595,13 @@ describe('@holo-js/forms contracts', () => { }, }, })).toBeUndefined() + expect(formsInternals.normalizeRequestLikeInput({ + req: { + headers: { + email: 'ava@example.com', + }, + }, + })).toBeUndefined() }) it('marks streamed request-like bodies as duplex requests', async () => { @@ -609,7 +633,7 @@ describe('@holo-js/forms contracts', () => { ])).toBe(true) expect(formsInternals.isRequestLikeHeaders({ host: 'forms.example.test', - })).toBe(true) + })).toBe(false) expect(formsInternals.isRequestLikeHeaders('accept: application/json')).toBe(false) const tupleHeaders = formsInternals.normalizeRequestHeaders([ @@ -627,6 +651,16 @@ describe('@holo-js/forms contracts', () => { expect(objectHeaders.get('cookie')).toBe('a=1; XSRF-TOKEN=token') expect(objectHeaders.get('x-trace')).toBe('trace-1,trace-2') + class GetOnlyHeaders { + get(name: string) { + return name === 'accept' ? 'application/json' : undefined + } + } + + expect(() => formsInternals.normalizeRequestHeaders(new GetOnlyHeaders())).toThrow( + new TypeError('get-only header accessor is not iterable.'), + ) + const ignoredHeadersRequest = formsInternals.normalizeRequestLikeInput({ web: { request: { @@ -768,9 +802,7 @@ describe('@holo-js/forms contracts', () => { }, }, }) - expect(defaultGetRequest?.method).toBe('GET') - expect(defaultGetRequest?.url).toBe('http://forms.example.test/') - expect(await defaultGetRequest?.text()).toBe('') + expect(defaultGetRequest).toBeUndefined() expect(formsInternals.normalizeRequestLikeInput(null)).toBeUndefined() })