diff --git a/apps/blog-next/app/auth-nav.tsx b/apps/blog-next/app/auth-nav.tsx
index 4c1c23b..98ce7f7 100644
--- a/apps/blog-next/app/auth-nav.tsx
+++ b/apps/blog-next/app/auth-nav.tsx
@@ -2,7 +2,7 @@
import { useState } from 'react'
import Link from 'next/link'
-import { useAuth } from '@holo-js/adapter-next/client'
+import { useAuth } from '@holo-js/auth/next/client'
const linkStyle = {
color: '#cbd5e1',
diff --git a/apps/blog-next/app/layout.tsx b/apps/blog-next/app/layout.tsx
index 8615d5d..577bf2a 100644
--- a/apps/blog-next/app/layout.tsx
+++ b/apps/blog-next/app/layout.tsx
@@ -1,8 +1,8 @@
import type { ReactNode } from 'react'
import type { Metadata } from 'next'
import Link from 'next/link'
-import { AuthProvider } from '@holo-js/adapter-next/client'
-import { auth } from '@holo-js/adapter-next/server'
+import { AuthProvider } from '@holo-js/auth/next/client'
+import { auth } from '@holo-js/auth/next/server'
import { AuthNav } from './auth-nav'
diff --git a/apps/blog-next/app/login/page.tsx b/apps/blog-next/app/login/page.tsx
index f3a136a..36914d2 100644
--- a/apps/blog-next/app/login/page.tsx
+++ b/apps/blog-next/app/login/page.tsx
@@ -2,7 +2,8 @@
import Link from 'next/link'
import { useRouter } from 'next/navigation'
-import { useAuth, useForm } from '@holo-js/adapter-next/client'
+import { useAuth } from '@holo-js/auth/next/client'
+import { useForm } from '@holo-js/adapter-next/client'
import { loginForm } from '@/lib/schemas/auth'
const panelStyle = {
diff --git a/apps/blog-next/app/verify-email/page.tsx b/apps/blog-next/app/verify-email/page.tsx
index 9b44079..ed708ab 100644
--- a/apps/blog-next/app/verify-email/page.tsx
+++ b/apps/blog-next/app/verify-email/page.tsx
@@ -5,7 +5,8 @@ import { useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
-import { useAuth, useForm } from '@holo-js/adapter-next/client'
+import { useAuth } from '@holo-js/auth/next/client'
+import { useForm } from '@holo-js/adapter-next/client'
import { verifyEmailForm } from '@/lib/schemas/auth'
diff --git a/apps/blog-next/proxy.ts b/apps/blog-next/proxy.ts
index 6f58786..f202051 100644
--- a/apps/blog-next/proxy.ts
+++ b/apps/blog-next/proxy.ts
@@ -1,10 +1,16 @@
-import { guestOnly } from '@holo-js/adapter-next/server'
+import { authOnly, guestOnly, protectRoutes } from '@holo-js/auth/next/server'
-export const proxy = guestOnly({
- routes: ['/login', '/register', '/forgot-password', '/reset-password'],
- redirectTo: '/admin',
-})
+export const proxy = protectRoutes(
+ guestOnly({
+ routes: ['/login', '/register', '/forgot-password', '/reset-password'],
+ redirectTo: '/admin',
+ }),
+ authOnly({
+ routes: ['/admin/*'],
+ redirectTo: '/login',
+ }),
+)
export const config = {
- matcher: ['/login', '/register', '/forgot-password', '/reset-password'],
+ matcher: ['/login', '/register', '/forgot-password', '/reset-password', '/admin/:path*'],
}
diff --git a/apps/blog-next/tests/run.mjs b/apps/blog-next/tests/run.mjs
index 836ba57..f66c5ab 100644
--- a/apps/blog-next/tests/run.mjs
+++ b/apps/blog-next/tests/run.mjs
@@ -154,6 +154,42 @@ async function waitForText(url, predicate, timeoutMs = 30000) {
throw new Error(`Timed out waiting for ${url}${lastError instanceof Error ? `: ${lastError.message}` : ''}`)
}
+async function waitForRedirect(url, expectedPath, timeoutMs = 30000) {
+ const startedAt = Date.now()
+ let lastError = null
+
+ while (Date.now() - startedAt < timeoutMs) {
+ const remainingMs = timeoutMs - (Date.now() - startedAt)
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), Math.max(100, Math.min(remainingMs, 5000)))
+ try {
+ const response = await fetch(url, {
+ redirect: 'manual',
+ signal: controller.signal,
+ })
+ const location = response.headers.get('location')
+ if (response.status >= 300 && response.status < 400 && location) {
+ const locationPath = new URL(location, url).pathname
+ if (locationPath === expectedPath) {
+ return
+ }
+
+ lastError = new Error(`Unexpected redirect ${response.status} to ${locationPath}`)
+ } else {
+ lastError = new Error(`Unexpected status ${response.status}`)
+ }
+ } catch (error) {
+ lastError = error
+ } finally {
+ clearTimeout(timeout)
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 250))
+ }
+
+ throw new Error(`Timed out waiting for ${url} to redirect to ${expectedPath}${lastError instanceof Error ? `: ${lastError.message}` : ''}`)
+}
+
function pipeOutput(stream, target) {
if (!stream) {
return
@@ -208,7 +244,7 @@ try {
const initial = await waitForJson(healthUrl, payload => payload.ok === true)
assert.equal(initial.app, 'blog-next')
await waitForText(`http://localhost:${port}/`, payload => payload.includes('Shipping a Real Holo Blog on Next'))
- await waitForText(`http://localhost:${port}/admin/posts`, payload => payload.includes('Designing the Example App Roadmap'))
+ await waitForRedirect(`http://localhost:${port}/admin/posts`, '/login')
await assertExampleAppAuthFlow({
baseUrl: `http://localhost:${port}`,
getOutput: () => capturedOutput,
diff --git a/apps/blog-nuxt/app/app.vue b/apps/blog-nuxt/app/app.vue
index dcdafa8..76c8749 100644
--- a/apps/blog-nuxt/app/app.vue
+++ b/apps/blog-nuxt/app/app.vue
@@ -1,4 +1,6 @@
```
```ts [SvelteKit — src/routes/+layout.server.ts]
-import { auth } from '@holo-js/adapter-sveltekit/server'
+import { auth } from '@holo-js/auth/sveltekit/server'
export async function load() {
return {
@@ -279,7 +286,7 @@ Nuxt's `useAuth()` is async because it uses Nuxt's server/client data fetching s
## Current User Endpoint
-The adapter helpers need an application-owned current-auth endpoint. The default endpoint is `/api/auth/user`.
+The framework auth helpers need an application-owned current-auth endpoint. The default endpoint is `/api/auth/user`.
::: code-group
@@ -322,7 +329,7 @@ export async function GET() {
:::
-For a named guard, pass `guard` to the adapter helper and return that guard's state from the endpoint.
+For a named guard, pass `guard` to the framework auth helper and return that guard's state from the endpoint.
## Types
@@ -334,15 +341,15 @@ If reusable library code really needs an explicit annotation, import the public
::: code-group
```ts [Next.js]
-import { type HoloAuthUser } from '@holo-js/adapter-next/client'
+import { type HoloAuthUser } from '@holo-js/auth/next/client'
```
```ts [Nuxt]
-import { type HoloAuthUser } from '@holo-js/adapter-nuxt/client'
+import { type HoloAuthUser } from '@holo-js/auth/nuxt'
```
```ts [SvelteKit]
-import { type HoloAuthUser } from '@holo-js/adapter-sveltekit/client'
+import { type HoloAuthUser } from '@holo-js/auth/sveltekit/client'
```
```ts [Framework-neutral]
diff --git a/apps/docs/docs/auth/index.md b/apps/docs/docs/auth/index.md
index a13dd07..da440c5 100644
--- a/apps/docs/docs/auth/index.md
+++ b/apps/docs/docs/auth/index.md
@@ -224,8 +224,127 @@ lookup for the selected guard.
## Protecting Routes
-Holo does not inject opinionated framework routes or route middleware for you. Route protection stays in your
-application code.
+Route protection stays explicit in your application code. The framework adapters provide server-side helpers for common
+page protection:
+
+- `authOnly(...)` redirects guests away from protected pages.
+- `guestOnly(...)` redirects signed-in users away from guest pages like login and register.
+
+Both helpers accept exact paths, wildcard paths such as `/admin/*`, RegExp matchers, or predicate functions.
+
+::: code-group
+
+```ts [Next.js 16 proxy.ts]
+import { authOnly, guestOnly, protectRoutes } from '@holo-js/auth/next/server'
+
+export const proxy = protectRoutes(
+ guestOnly({
+ routes: ['/login', '/register', '/forgot-password', '/reset-password'],
+ redirectTo: '/admin',
+ }),
+ authOnly({
+ routes: ['/admin/*'],
+ redirectTo: '/login',
+ }),
+)
+
+export const config = {
+ matcher: ['/login', '/register', '/forgot-password', '/reset-password', '/admin/:path*'],
+}
+```
+
+```ts [Nuxt 4 app/middleware/auth-only.global.ts]
+import { authOnly } from '@holo-js/auth/nuxt/server'
+
+export default authOnly({
+ routes: ['/admin/*'],
+ redirectTo: '/login',
+})
+```
+
+```ts [Nuxt 4 app/middleware/guest-only.global.ts]
+import { guestOnly } from '@holo-js/auth/nuxt/server'
+
+export default guestOnly({
+ routes: ['/login', '/register', '/forgot-password', '/reset-password'],
+ redirectTo: '/admin',
+})
+```
+
+```ts [SvelteKit hooks.server.ts]
+import { sequence } from '@sveltejs/kit/hooks'
+import { authOnly, guestOnly } from '@holo-js/auth/sveltekit/server'
+
+export const handle = sequence(
+ guestOnly({
+ routes: ['/login', '/register', '/forgot-password', '/reset-password'],
+ redirectTo: '/admin',
+ }),
+ authOnly({
+ routes: ['/admin/*'],
+ redirectTo: '/login',
+ }),
+)
+```
+
+:::
+
+You can compose your own framework middleware with the Holo helpers. Keep custom logic in the same native entrypoint and
+return a response only when it wants to stop the request.
+
+::: code-group
+
+```ts [Next.js 16 proxy.ts]
+import { authOnly, protectRoutes } from '@holo-js/auth/next/server'
+
+function maintenanceProxy() {
+ if (process.env.MAINTENANCE_MODE === 'true') {
+ return new Response('Down for maintenance.', { status: 503 })
+ }
+}
+
+export const proxy = protectRoutes(
+ maintenanceProxy,
+ authOnly({
+ routes: ['/admin/*'],
+ redirectTo: '/login',
+ }),
+)
+```
+
+```ts [SvelteKit hooks.server.ts]
+import { sequence } from '@sveltejs/kit/hooks'
+import { authOnly } from '@holo-js/auth/sveltekit/server'
+import { MAINTENANCE_MODE } from '$env/static/private'
+
+export const handle = sequence(
+ ({ event, resolve }) => {
+ if (event.url.pathname.startsWith('/admin') && MAINTENANCE_MODE === 'true') {
+ return new Response('Down for maintenance.', { status: 503 })
+ }
+
+ return resolve(event)
+ },
+ authOnly({
+ routes: ['/admin/*'],
+ redirectTo: '/login',
+ }),
+)
+```
+
+```ts [Nuxt 4 app/middleware/maintenance.global.ts]
+export default defineNuxtRouteMiddleware((to) => {
+ const config = useRuntimeConfig()
+
+ if (to.path.startsWith('/admin') && config.public.maintenanceMode === true) {
+ return abortNavigation('Down for maintenance.')
+ }
+})
+```
+
+:::
+
+For API handlers, return a `401` from the server boundary:
```ts
import { check } from '@holo-js/auth'
diff --git a/apps/docs/docs/forms/framework-integration.md b/apps/docs/docs/forms/framework-integration.md
index 52ec428..c9f424c 100644
--- a/apps/docs/docs/forms/framework-integration.md
+++ b/apps/docs/docs/forms/framework-integration.md
@@ -118,7 +118,8 @@ not h3 route handlers.
::: code-group
```ts [Next.js — app/login/page.tsx]
-import { useAuth, useForm } from '@holo-js/adapter-next/client'
+import { useAuth } from '@holo-js/auth/next/client'
+import { useForm } from '@holo-js/adapter-next/client'
import { loginForm } from '@/lib/schemas/login'
const auth = useAuth()
@@ -137,6 +138,7 @@ const form = useForm(loginForm, {
```
```ts [Nuxt — pages/login.vue]
+import { useAuth } from '@holo-js/auth/nuxt'
import { useForm } from '@holo-js/adapter-nuxt/client'
import { loginForm } from '~/lib/schemas/login'
@@ -156,7 +158,8 @@ const form = useForm(loginForm, {
```ts [SvelteKit — src/routes/login/+page.svelte]
import { invalidateAll } from '$app/navigation'
-import { useAuth, useForm } from '@holo-js/adapter-sveltekit/client'
+import { useAuth } from '@holo-js/auth/sveltekit/client'
+import { useForm } from '@holo-js/adapter-sveltekit/client'
import { loginForm } from '$lib/schemas/login'
const auth = useAuth()
diff --git a/apps/docs/docs/forms/server-validation.md b/apps/docs/docs/forms/server-validation.md
index 9b07bec..ea28f1b 100644
--- a/apps/docs/docs/forms/server-validation.md
+++ b/apps/docs/docs/forms/server-validation.md
@@ -247,7 +247,8 @@ These examples show the real failure and success handling path using `useForm(..
```tsx [Next.js — app/login/page.tsx]
'use client'
-import { useAuth, useForm } from '@holo-js/adapter-next/client'
+import { useAuth } from '@holo-js/auth/next/client'
+import { useForm } from '@holo-js/adapter-next/client'
import { loginForm } from '@/lib/schemas/login'
export default function LoginPage() {
@@ -308,6 +309,7 @@ export default function LoginPage() {
```vue [Nuxt — pages/login.vue]
+
+
+```
+
Bind displayed values from `form.values.*` across frameworks and keep `form.fields.*` for field lifecycle helpers.
`form.fields.email.onBlur()` is the blur-validation hook when `validateOn: 'blur'` is enabled, while touched
state can also be set during input and value updates through helpers like `form.fields.email.onInput(...)`
diff --git a/bun.lock b/bun.lock
index 6711fbb..953a2c3 100644
--- a/bun.lock
+++ b/bun.lock
@@ -283,7 +283,6 @@
"name": "@holo-js/adapter-next",
"version": "0.1.4",
"dependencies": {
- "@holo-js/auth": "^0.1.4",
"@holo-js/config": "^0.1.4",
"@holo-js/core": "^0.1.4",
},
@@ -305,7 +304,6 @@
"name": "@holo-js/adapter-nuxt",
"version": "0.1.4",
"dependencies": {
- "@holo-js/auth": "^0.1.4",
"@holo-js/config": "^0.1.4",
"@holo-js/core": "^0.1.4",
"@holo-js/db": "^0.1.4",
@@ -334,7 +332,6 @@
"name": "@holo-js/adapter-sveltekit",
"version": "0.1.4",
"dependencies": {
- "@holo-js/auth": "^0.1.4",
"@holo-js/config": "^0.1.4",
"@holo-js/core": "^0.1.4",
"svelte": "catalog:",
@@ -360,15 +357,25 @@
},
"devDependencies": {
"@types/node": "^22.10.2",
+ "@types/react": "^19.0.0",
+ "nuxt": "catalog:",
+ "react": "^19.0.0",
+ "svelte": "catalog:",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "catalog:",
},
"peerDependencies": {
"@holo-js/security": "^0.1.4",
+ "nuxt": "catalog:",
+ "react": "^18.3.1 || ^19.0.0",
+ "svelte": "catalog:",
},
"optionalPeers": [
"@holo-js/security",
+ "nuxt",
+ "react",
+ "svelte",
],
},
"packages/auth-clerk": {
@@ -1892,7 +1899,7 @@
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
- "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
+ "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@@ -3384,6 +3391,8 @@
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "@docsearch/react/@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
+
"@docsearch/react/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"@dxup/nuxt/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
@@ -3392,6 +3401,8 @@
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
+ "@holo-js/flux-react/@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
+
"@holo-js/flux-react/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
@@ -3488,7 +3499,7 @@
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
- "@types/react-dom/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
+ "@types/react-test-renderer/@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
@@ -3552,8 +3563,6 @@
"autoprefixer/caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="],
- "blog-next/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
-
"blog-next/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"blog-nuxt/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
@@ -3628,8 +3637,6 @@
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
- "next_test_app/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
-
"nitropack/@rollup/plugin-alias": ["@rollup/plugin-alias@6.0.0", "", { "peerDependencies": { "rollup": ">=4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g=="],
"nitropack/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
diff --git a/packages/adapter-next/package.json b/packages/adapter-next/package.json
index 1c70b0d..d3a4a8c 100644
--- a/packages/adapter-next/package.json
+++ b/packages/adapter-next/package.json
@@ -20,11 +20,6 @@
"import": "./dist/client.mjs",
"default": "./dist/client.mjs"
},
- "./server": {
- "types": "./dist/server.d.ts",
- "import": "./dist/server.mjs",
- "default": "./dist/server.mjs"
- },
"./runtime": {
"types": "./dist/runtime.d.ts",
"import": "./dist/runtime.mjs",
@@ -43,7 +38,6 @@
"test": "vitest --run"
},
"dependencies": {
- "@holo-js/auth": "^0.1.4",
"@holo-js/config": "^0.1.4",
"@holo-js/core": "^0.1.4"
},
diff --git a/packages/adapter-next/src/client.ts b/packages/adapter-next/src/client.ts
index 2b5ebdb..16c1cec 100644
--- a/packages/adapter-next/src/client.ts
+++ b/packages/adapter-next/src/client.ts
@@ -1,8 +1,6 @@
'use client'
-import { createContext, createElement, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
-import { refreshUser as refreshCurrentUser } from '@holo-js/auth/client'
-import type { AuthClientRequestOptions, HoloAuthUser } from '@holo-js/auth/client'
+import { useEffect, useRef, useState } from 'react'
import type { FormSchema, InferFormData } from '@holo-js/forms'
import {
type ClientSubmitContext,
@@ -22,73 +20,6 @@ export {
type UseFormResult,
type ValidateOnMode,
} from '@holo-js/forms/client'
-export type { HoloAuthUser } from '@holo-js/auth/client'
-
-type UseAuthRequestOptions = Pick
-
-export type UseAuthOptions = UseAuthRequestOptions & {
- readonly initialUser?: HoloAuthUser | null
-}
-
-export type UseAuthResult = {
- readonly authenticated: boolean
- readonly user: HoloAuthUser | null
- readonly refreshUser: () => Promise
-}
-
-export type AuthProviderProps = UseAuthOptions & {
- readonly children: ReactNode
-}
-
-const AuthContext = createContext(null)
-
-function useAuthState(
- options: UseAuthOptions = {},
- stateOptions: { readonly refreshOnMount?: boolean } = {},
-): UseAuthResult {
- const { initialUser, ...requestOptions } = options
- const [currentUser, setCurrentUser] = useState(initialUser ?? null)
- const requestOptionsRef = useRef(requestOptions)
-
- requestOptionsRef.current = requestOptions
-
- const refreshUser = useCallback(async () => {
- const nextUser = await refreshCurrentUser(requestOptionsRef.current)
- setCurrentUser(nextUser)
- return nextUser
- }, [])
-
- useEffect(() => {
- if (stateOptions.refreshOnMount !== false && typeof initialUser === 'undefined') {
- void refreshUser()
- }
- }, [initialUser, refreshUser, stateOptions.refreshOnMount])
-
- return {
- authenticated: currentUser !== null,
- user: currentUser,
- refreshUser,
- }
-}
-
-export function AuthProvider({ children, ...options }: AuthProviderProps): ReactNode {
- const auth = useAuthState(options)
-
- return createElement(AuthContext.Provider, { value: auth }, children)
-}
-
-export function useAuth(options?: UseAuthOptions): UseAuthResult {
- const context = useContext(AuthContext)
- const localAuth = useAuthState(options, {
- refreshOnMount: Boolean(options) || !context,
- })
-
- if (!options && context) {
- return context
- }
-
- return localAuth
-}
function isPlainObject(value: unknown): value is Record {
return !!value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof Blob)
diff --git a/packages/adapter-next/src/request-context.ts b/packages/adapter-next/src/request-context.ts
index 4dc2ab2..0ba1783 100644
--- a/packages/adapter-next/src/request-context.ts
+++ b/packages/adapter-next/src/request-context.ts
@@ -7,15 +7,24 @@ export type NextRequestLike = {
readonly headers: Headers
}
-const nextRequestStore = new AsyncLocalStorage()
+type NextRequestGlobals = typeof globalThis & {
+ __holoNextAuthRequestStore?: AsyncLocalStorage
+}
+
+function getNextRequestStore(): AsyncLocalStorage {
+ const globals = globalThis as NextRequestGlobals
+ globals.__holoNextAuthRequestStore ??= new AsyncLocalStorage()
+
+ return globals.__holoNextAuthRequestStore
+}
export function getCurrentNextRequest(): NextRequestLike | undefined {
- return nextRequestStore.getStore()
+ return getNextRequestStore().getStore()
}
export function runWithNextRequest(
request: NextRequestLike,
callback: () => TValue,
): TValue {
- return nextRequestStore.run(request, callback)
+ return getNextRequestStore().run(request, callback)
}
diff --git a/packages/adapter-next/src/server.ts b/packages/adapter-next/src/server.ts
deleted file mode 100644
index a9d23cc..0000000
--- a/packages/adapter-next/src/server.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import holoAuth, { user as currentUser } from '@holo-js/auth'
-import type { HoloAuthUser } from '@holo-js/auth'
-import { runWithNextRequest, type NextRequestLike } from './request-context'
-
-export type AuthState = {
- readonly authenticated: boolean
- readonly user: HoloAuthUser | null
-}
-
-export type AuthOptions = {
- readonly guard?: string
-}
-
-export type RouteMatcher = string | RegExp | ((pathname: string) => boolean)
-
-export type GuestOnlyOptions = AuthOptions & {
- readonly redirectTo: string
- readonly routes?: readonly RouteMatcher[]
- readonly status?: 301 | 302 | 303 | 307 | 308
-}
-
-export type NextGuestOnlyRequest = NextRequestLike & {
- readonly nextUrl?: URL
- readonly url: string
-}
-
-function toClientAuthUser(user: HoloAuthUser | null): HoloAuthUser | null {
- return user ? { ...user } : null
-}
-
-function normalizePathname(pathname: string): string {
- if (pathname === '/') {
- return pathname
- }
-
- return pathname.replace(/\/+$/g, '')
-}
-
-function matchesRoute(route: RouteMatcher, pathname: string): boolean {
- const normalizedPathname = normalizePathname(pathname)
- if (typeof route === 'function') {
- return route(normalizedPathname)
- }
-
- if (route instanceof RegExp) {
- route.lastIndex = 0
- return route.test(normalizedPathname)
- }
-
- const normalizedRoute = normalizePathname(route)
- if (normalizedRoute.endsWith('/*')) {
- const prefix = normalizePathname(normalizedRoute.slice(0, -2))
- return normalizedPathname === prefix || normalizedPathname.startsWith(`${prefix}/`)
- }
-
- return normalizedPathname === normalizedRoute
-}
-
-function matchesRoutes(routes: readonly RouteMatcher[] | undefined, pathname: string): boolean {
- return (routes ?? ['/*']).some(route => matchesRoute(route, pathname))
-}
-
-export async function auth(options: AuthOptions = {}): Promise {
- const user = options.guard
- ? await holoAuth.guard(options.guard).user()
- : await currentUser()
- const clientUser = toClientAuthUser(user)
-
- return {
- authenticated: clientUser !== null,
- user: clientUser,
- }
-}
-
-export function guestOnly(options: GuestOnlyOptions) {
- return async function proxy(request: NextGuestOnlyRequest): Promise {
- const requestUrl = request.nextUrl ?? new URL(request.url)
- if (!matchesRoutes(options.routes, requestUrl.pathname)) {
- return undefined
- }
-
- return runWithNextRequest(request, async () => {
- const currentAuth = await auth({ guard: options.guard })
- if (!currentAuth.authenticated) {
- return undefined
- }
-
- return Response.redirect(new URL(options.redirectTo, request.url), options.status ?? 303)
- })
- }
-}
-
-export const routeProtectionInternals = {
- matchesRoute,
- matchesRoutes,
-}
diff --git a/packages/adapter-next/tests/adapter.type.test.ts b/packages/adapter-next/tests/adapter.type.test.ts
index b826c48..d7cdf09 100644
--- a/packages/adapter-next/tests/adapter.type.test.ts
+++ b/packages/adapter-next/tests/adapter.type.test.ts
@@ -108,8 +108,9 @@ describe('@holo-js/adapter-next typing', () => {
await writeFile(
entryPath,
[
- `import { AuthProvider, useAuth, useForm, type HoloAuthUser, type UseAuthResult } from '@holo-js/adapter-next/client'`,
- `import { auth } from '@holo-js/adapter-next/server'`,
+ `import { AuthProvider, useAuth, type HoloAuthUser, type UseAuthResult } from '@holo-js/auth/next/client'`,
+ `import { auth } from '@holo-js/auth/next/server'`,
+ `import { useForm } from '@holo-js/adapter-next/client'`,
`const currentAuth = useAuth()`,
`const user: HoloAuthUser | null = currentAuth.user`,
`const authResult: UseAuthResult = currentAuth`,
diff --git a/packages/adapter-next/tests/client.test.ts b/packages/adapter-next/tests/client.test.ts
index 226a52e..004b67d 100644
--- a/packages/adapter-next/tests/client.test.ts
+++ b/packages/adapter-next/tests/client.test.ts
@@ -41,182 +41,9 @@ describe('@holo-js/adapter-next client', () => {
vi.resetModules()
vi.clearAllMocks()
vi.doUnmock('react')
- vi.doUnmock('@holo-js/auth/client')
vi.doUnmock('@holo-js/forms/client')
})
- it('exposes current user state through the auth client helper', async () => {
- const refreshedUser = {
- id: 2,
- email: 'nora@example.com',
- name: 'Nora',
- }
- const hookStates: unknown[] = []
- let hookStateIndex = 0
-
- vi.doMock('@holo-js/auth/client', () => ({
- refreshUser: vi.fn(async () => refreshedUser),
- }))
-
- vi.doMock('react', () => createReactMock({
- useCallback unknown>(callback: TCallback) {
- return callback
- },
- useEffect() {},
- useRef(initialValue?: TValue) {
- return { current: initialValue }
- },
- useState(initialState: TValue | (() => TValue)) {
- const stateIndex = hookStateIndex
- hookStateIndex += 1
- hookStates[stateIndex] = typeof initialState === 'function'
- ? (initialState as () => TValue)()
- : initialState
-
- return [hookStates[stateIndex] as TValue, (next: TValue | ((previous: TValue) => TValue)) => {
- hookStates[stateIndex] = typeof next === 'function'
- ? (next as (previous: TValue) => TValue)(hookStates[stateIndex] as TValue)
- : next
- }] as const
- },
- }))
-
- const { useAuth } = await import('../src/client')
- const auth = useAuth({
- initialUser: {
- id: 1,
- email: 'ava@example.com',
- name: 'Ava',
- },
- })
-
- expect(auth.authenticated).toBe(true)
- expect(auth.user?.email).toBe('ava@example.com')
- await expect(auth.refreshUser()).resolves.toEqual(refreshedUser)
- expect(hookStates[0]).toEqual(refreshedUser)
- })
-
- it('shares the provider user with auth hooks that do not pass options', async () => {
- const initialUser = {
- id: 1,
- email: 'ava@example.com',
- name: 'Ava',
- }
-
- vi.doMock('@holo-js/auth/client', () => ({
- refreshUser: vi.fn(),
- }))
-
- vi.doMock('react', () => createReactMock({
- useCallback unknown>(callback: TCallback) {
- return callback
- },
- useEffect() {},
- useRef(initialValue?: TValue) {
- return { current: initialValue }
- },
- useState(initialState: TValue | (() => TValue)) {
- const value = typeof initialState === 'function'
- ? (initialState as () => TValue)()
- : initialState
-
- return [value, vi.fn()] as const
- },
- }))
-
- const { AuthProvider, useAuth } = await import('../src/client')
-
- AuthProvider({
- initialUser,
- children: null,
- })
-
- const auth = useAuth()
-
- expect(auth.authenticated).toBe(true)
- expect(auth.user).toEqual(initialUser)
- })
-
- it('fetches the current user when no initial user is provided', async () => {
- const refreshedUser = {
- id: 3,
- email: 'mina@example.com',
- name: 'Mina',
- }
- const hookStates: unknown[] = []
- let hookStateIndex = 0
-
- vi.doMock('@holo-js/auth/client', () => ({
- refreshUser: vi.fn(async () => refreshedUser),
- }))
-
- vi.doMock('react', () => createReactMock({
- useCallback unknown>(callback: TCallback) {
- return callback
- },
- useEffect(effect: () => void | (() => void)) {
- void effect()
- },
- useRef(initialValue?: TValue) {
- return { current: initialValue }
- },
- useState(initialState: TValue | (() => TValue)) {
- const stateIndex = hookStateIndex
- hookStateIndex += 1
- hookStates[stateIndex] = typeof initialState === 'function'
- ? (initialState as () => TValue)()
- : initialState
-
- return [hookStates[stateIndex] as TValue, (next: TValue | ((previous: TValue) => TValue)) => {
- hookStates[stateIndex] = typeof next === 'function'
- ? (next as (previous: TValue) => TValue)(hookStates[stateIndex] as TValue)
- : next
- }] as const
- },
- }))
-
- const { useAuth } = await import('../src/client')
-
- useAuth()
- await new Promise(resolve => setTimeout(resolve, 0))
-
- expect(hookStates[0]).toEqual(refreshedUser)
- })
-
- it('does not refresh automatically when an initial user value is provided', async () => {
- const refreshUser = vi.fn()
-
- vi.doMock('@holo-js/auth/client', () => ({
- refreshUser,
- }))
-
- vi.doMock('react', () => createReactMock({
- useCallback unknown>(callback: TCallback) {
- return callback
- },
- useEffect(effect: () => void | (() => void)) {
- void effect()
- },
- useRef(initialValue?: TValue) {
- return { current: initialValue }
- },
- useState(initialState: TValue | (() => TValue)) {
- const value = typeof initialState === 'function'
- ? (initialState as () => TValue)()
- : initialState
-
- return [value, vi.fn()] as const
- },
- }))
-
- const { useAuth } = await import('../src/client')
-
- const auth = useAuth({ initialUser: null })
-
- expect(auth.authenticated).toBe(false)
- expect(refreshUser).not.toHaveBeenCalled()
- })
-
it('wraps the shared form client with a React subscription bridge', async () => {
const rerenders: number[] = []
let subscribedListener: (() => void) | undefined
diff --git a/packages/adapter-next/tests/package.test.ts b/packages/adapter-next/tests/package.test.ts
index 6b83526..af7d5e7 100644
--- a/packages/adapter-next/tests/package.test.ts
+++ b/packages/adapter-next/tests/package.test.ts
@@ -3,19 +3,26 @@ import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
describe('@holo-js/adapter-next package boundaries', () => {
- it('keeps forms and auth client surfaces on the correct dependency boundaries', async () => {
+ it('keeps forms client surface optional and leaves auth helpers in @holo-js/auth', async () => {
const packageJsonPath = resolve(import.meta.dirname, '../package.json')
+ const indexEntryPath = resolve(import.meta.dirname, '../src/index.ts')
const clientEntryPath = resolve(import.meta.dirname, '../src/client.ts')
+ const runtimeEntryPath = resolve(import.meta.dirname, '../src/runtime.ts')
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
dependencies?: Record
peerDependencies?: Record
peerDependenciesMeta?: Record
}
+ const indexEntry = await readFile(indexEntryPath, 'utf8')
const clientEntry = await readFile(clientEntryPath, 'utf8')
+ const runtimeEntry = await readFile(runtimeEntryPath, 'utf8')
+ expect(indexEntry).not.toContain("@holo-js/forms")
expect(clientEntry).toContain("@holo-js/forms/client")
- expect(clientEntry).toContain("@holo-js/auth/client")
- expect(packageJson.dependencies?.['@holo-js/auth']).toBeDefined()
+ expect(clientEntry).not.toContain("@holo-js/auth")
+ expect(runtimeEntry).not.toContain("@holo-js/forms")
+ expect(runtimeEntry).not.toContain("@holo-js/auth")
+ expect(packageJson.dependencies?.['@holo-js/auth']).toBeUndefined()
expect(packageJson.dependencies?.['@holo-js/forms']).toBeUndefined()
expect(packageJson.peerDependencies?.['@holo-js/forms']).toBeDefined()
expect(packageJson.peerDependencies?.react).toBeDefined()
diff --git a/packages/adapter-next/tests/server.test.ts b/packages/adapter-next/tests/server.test.ts
deleted file mode 100644
index 35ce156..0000000
--- a/packages/adapter-next/tests/server.test.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest'
-
-const authProviderMarker = Symbol.for('holo-js.auth.provider')
-
-describe('@holo-js/adapter-next server auth', () => {
- afterEach(() => {
- vi.resetModules()
- vi.doUnmock('@holo-js/auth')
- })
-
- it('returns a serializable current user without auth runtime symbol metadata', async () => {
- const runtimeUser = {
- id: 1,
- email: 'ava@example.com',
- name: 'Ava',
- }
- Object.defineProperty(runtimeUser, authProviderMarker, {
- value: 'users',
- })
-
- vi.doMock('@holo-js/auth', () => ({
- default: {
- guard: vi.fn(),
- },
- user: vi.fn(async () => runtimeUser),
- }))
-
- const { auth } = await import('../src/server')
- const currentAuth = await auth()
-
- expect(currentAuth.authenticated).toBe(true)
- expect(currentAuth.user).toEqual(runtimeUser)
- expect(currentAuth.user).not.toBe(runtimeUser)
- expect(Object.getOwnPropertySymbols(currentAuth.user!)).not.toContain(authProviderMarker)
- })
-
- it('returns a guest auth state when no user is authenticated', async () => {
- vi.doMock('@holo-js/auth', () => ({
- default: {
- guard: vi.fn(),
- },
- user: vi.fn(async () => null),
- }))
-
- const { auth } = await import('../src/server')
- const currentAuth = await auth()
-
- expect(currentAuth).toEqual({
- authenticated: false,
- user: null,
- })
- })
-
- it('resolves named guards through the auth facade', async () => {
- const guardUser = {
- id: 2,
- email: 'admin@example.com',
- name: 'Admin',
- }
- const guard = vi.fn(() => ({
- user: vi.fn(async () => guardUser),
- }))
-
- vi.doMock('@holo-js/auth', () => ({
- default: {
- guard,
- },
- user: vi.fn(),
- }))
-
- const { auth } = await import('../src/server')
- const currentAuth = await auth({ guard: 'admin' })
-
- expect(guard).toHaveBeenCalledWith('admin')
- expect(currentAuth.user).toEqual(guardUser)
- })
-
- it('redirects authenticated users from guest-only proxy routes', async () => {
- vi.doMock('@holo-js/auth', () => ({
- default: {
- guard: vi.fn(),
- },
- user: vi.fn(async () => ({ id: 1, email: 'ava@example.com' })),
- }))
-
- const { guestOnly } = await import('../src/server')
- const proxy = guestOnly({
- routes: ['/login', '/register', '/auth/*'],
- redirectTo: '/admin',
- })
- const response = await proxy({
- url: 'https://app.test/login',
- nextUrl: new URL('https://app.test/login'),
- headers: new Headers(),
- cookies: {
- get: () => undefined,
- },
- })
-
- expect(response?.status).toBe(303)
- expect(response?.headers.get('location')).toBe('https://app.test/admin')
- })
-
- it('continues for guest-only proxy routes when no user is authenticated', async () => {
- vi.doMock('@holo-js/auth', () => ({
- default: {
- guard: vi.fn(),
- },
- user: vi.fn(async () => null),
- }))
-
- const { guestOnly } = await import('../src/server')
- const proxy = guestOnly({
- routes: ['/login'],
- redirectTo: '/admin',
- })
-
- await expect(proxy({
- url: 'https://app.test/login',
- nextUrl: new URL('https://app.test/login'),
- headers: new Headers(),
- cookies: {
- get: () => undefined,
- },
- })).resolves.toBeUndefined()
- })
-
- it('supports wildcard route matching for guest-only proxy routes', async () => {
- const { routeProtectionInternals } = await import('../src/server')
-
- expect(routeProtectionInternals.matchesRoutes(['/auth/*'], '/auth')).toBe(true)
- expect(routeProtectionInternals.matchesRoutes(['/auth/*'], '/auth/reset')).toBe(true)
- expect(routeProtectionInternals.matchesRoutes(['/auth/*'], '/login')).toBe(false)
-
- const statefulRoute = /^\/auth/g
- expect(routeProtectionInternals.matchesRoutes([statefulRoute], '/auth')).toBe(true)
- expect(routeProtectionInternals.matchesRoutes([statefulRoute], '/auth')).toBe(true)
- })
-})
diff --git a/packages/adapter-next/tsconfig.json b/packages/adapter-next/tsconfig.json
index d661263..dc585df 100644
--- a/packages/adapter-next/tsconfig.json
+++ b/packages/adapter-next/tsconfig.json
@@ -4,11 +4,6 @@
"outDir": "dist",
"baseUrl": ".",
"paths": {
- "@holo-js/auth": ["../auth/src/index.ts"],
- "@holo-js/auth/client": ["../auth/src/client.ts"],
- "@holo-js/auth-clerk": ["../auth-clerk/src/index.ts"],
- "@holo-js/auth-social": ["../auth-social/src/index.ts"],
- "@holo-js/auth-workos": ["../auth-workos/src/index.ts"],
"@holo-js/config": ["../config/src/index.ts"],
"@holo-js/core": ["../core/src/index.ts"],
"@holo-js/forms": ["../forms/src/index.ts"],
diff --git a/packages/adapter-next/tsup.config.ts b/packages/adapter-next/tsup.config.ts
index 9a203eb..563942e 100644
--- a/packages/adapter-next/tsup.config.ts
+++ b/packages/adapter-next/tsup.config.ts
@@ -5,7 +5,6 @@ export default defineConfig({
index: 'src/index.ts',
config: 'src/config.ts',
client: 'src/client.ts',
- server: 'src/server.ts',
runtime: 'src/runtime.ts',
},
format: ['esm'],
diff --git a/packages/adapter-next/vitest.config.ts b/packages/adapter-next/vitest.config.ts
index 460979c..b866dd7 100644
--- a/packages/adapter-next/vitest.config.ts
+++ b/packages/adapter-next/vitest.config.ts
@@ -4,11 +4,6 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
alias: {
- '@holo-js/auth/client': resolve(__dirname, '../auth/src/client.ts'),
- '@holo-js/auth': resolve(__dirname, '../auth/src/index.ts'),
- '@holo-js/auth-social': resolve(__dirname, '../auth-social/src/index.ts'),
- '@holo-js/auth-workos': resolve(__dirname, '../auth-workos/src/index.ts'),
- '@holo-js/auth-clerk': resolve(__dirname, '../auth-clerk/src/index.ts'),
'@holo-js/config': resolve(__dirname, '../config/src/index.ts'),
'@holo-js/core': resolve(__dirname, '../core/src/index.ts'),
'@holo-js/db': resolve(__dirname, '../db/src/index.ts'),
diff --git a/packages/adapter-nuxt/package.json b/packages/adapter-nuxt/package.json
index 83e874d..aa1dadd 100644
--- a/packages/adapter-nuxt/package.json
+++ b/packages/adapter-nuxt/package.json
@@ -17,14 +17,6 @@
"types": "./dist/runtime/composables/index.d.ts",
"import": "./dist/runtime/composables/index.js"
},
- "./auth": {
- "types": "./dist/runtime/composables/auth.d.ts",
- "import": "./dist/runtime/composables/auth.js"
- },
- "./server": {
- "types": "./dist/runtime/server/protection.d.ts",
- "import": "./dist/runtime/server/protection.js"
- },
"./storage": {
"types": "./dist/runtime/composables/storage.d.ts",
"import": "./dist/runtime/composables/storage.js"
@@ -43,7 +35,6 @@
},
"dependencies": {
"@nuxt/kit": "catalog:",
- "@holo-js/auth": "^0.1.4",
"@holo-js/config": "^0.1.4",
"@holo-js/core": "^0.1.4",
"@holo-js/db": "^0.1.4"
diff --git a/packages/adapter-nuxt/src/module.ts b/packages/adapter-nuxt/src/module.ts
index 8892933..d0c24bd 100644
--- a/packages/adapter-nuxt/src/module.ts
+++ b/packages/adapter-nuxt/src/module.ts
@@ -331,7 +331,6 @@ export default defineNuxtModule({
{ name: 'useHoloDb', as: 'useHoloDb', from: '@holo-js/adapter-nuxt/runtime' },
{ name: 'useHoloEnv', as: 'useHoloEnv', from: '@holo-js/adapter-nuxt/runtime' },
{ name: 'useHoloDebug', as: 'useHoloDebug', from: '@holo-js/adapter-nuxt/runtime' },
- { name: 'useAuth', as: 'useAuth', from: '@holo-js/adapter-nuxt/auth' },
]
if (storageModule) {
imports.push(
diff --git a/packages/adapter-nuxt/src/runtime/composables/auth.d.ts b/packages/adapter-nuxt/src/runtime/composables/auth.d.ts
deleted file mode 100644
index 409985b..0000000
--- a/packages/adapter-nuxt/src/runtime/composables/auth.d.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { HoloAuthUser } from '@holo-js/auth'
-
-export type { HoloAuthUser } from '@holo-js/auth'
-
-export interface HoloAuthRef {
- value: TValue
-}
-
-export interface HoloAuthComputedRef {
- readonly value: TValue
-}
-
-export interface UseAuthOptions {
- readonly endpoint?: string
- readonly guard?: string
- readonly key?: string
-}
-
-export interface UseAuthResult {
- readonly authenticated: HoloAuthComputedRef
- readonly user: HoloAuthRef
- readonly refreshUser: () => Promise
-}
-
-export declare function useAuth(options?: UseAuthOptions): Promise
diff --git a/packages/adapter-nuxt/src/runtime/server/protection.d.ts b/packages/adapter-nuxt/src/runtime/server/protection.d.ts
deleted file mode 100644
index efa65b1..0000000
--- a/packages/adapter-nuxt/src/runtime/server/protection.d.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-export type RouteMatcher = string | RegExp | ((pathname: string) => boolean)
-
-export type GuestOnlyOptions = {
- readonly redirectTo: string
- readonly routes?: readonly RouteMatcher[]
- readonly status?: 301 | 302 | 303 | 307 | 308
-}
-
-export type GuestOnlyRouteLocation = {
- readonly path: string
-}
-
-export type GuestOnlyRouteMiddlewareResult = void | false | Promise
-
-export type GuestOnlyRouteMiddleware = (
- to: GuestOnlyRouteLocation,
- from: GuestOnlyRouteLocation,
-) => GuestOnlyRouteMiddlewareResult
-
-export declare function guestOnly(options: GuestOnlyOptions): GuestOnlyRouteMiddleware
-
-export declare const routeProtectionInternals: {
- readonly matchesRoute: (route: RouteMatcher, pathname: string) => boolean
- readonly matchesRoutes: (routes: readonly RouteMatcher[] | undefined, pathname: string) => boolean
-}
diff --git a/packages/adapter-nuxt/src/runtime/server/protection.ts b/packages/adapter-nuxt/src/runtime/server/protection.ts
deleted file mode 100644
index c677bdc..0000000
--- a/packages/adapter-nuxt/src/runtime/server/protection.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { useAuth } from '../composables/auth'
-import { defineNuxtRouteMiddleware, navigateTo } from '#imports'
-
-export type RouteMatcher = string | RegExp | ((pathname: string) => boolean)
-
-export type GuestOnlyOptions = {
- readonly redirectTo: string
- readonly routes?: readonly RouteMatcher[]
- readonly status?: 301 | 302 | 303 | 307 | 308
-}
-
-export type GuestOnlyRouteLocation = {
- readonly path: string
-}
-
-export type GuestOnlyRouteMiddlewareResult = void | false | Promise
-
-export type GuestOnlyRouteMiddleware = (
- to: GuestOnlyRouteLocation,
- from: GuestOnlyRouteLocation,
-) => GuestOnlyRouteMiddlewareResult
-
-function normalizePathname(pathname: string): string {
- if (pathname === '/') {
- return pathname
- }
-
- return pathname.replace(/\/+$/g, '')
-}
-
-function matchesRoute(route: RouteMatcher, pathname: string): boolean {
- const normalizedPathname = normalizePathname(pathname)
- if (typeof route === 'function') {
- return route(normalizedPathname)
- }
-
- if (route instanceof RegExp) {
- route.lastIndex = 0
- return route.test(normalizedPathname)
- }
-
- const normalizedRoute = normalizePathname(route)
- if (normalizedRoute.endsWith('/*')) {
- const prefix = normalizePathname(normalizedRoute.slice(0, -2))
- return normalizedPathname === prefix || normalizedPathname.startsWith(`${prefix}/`)
- }
-
- return normalizedPathname === normalizedRoute
-}
-
-function matchesRoutes(routes: readonly RouteMatcher[] | undefined, pathname: string): boolean {
- return (routes ?? ['/*']).some(route => matchesRoute(route, pathname))
-}
-
-export function guestOnly(options: GuestOnlyOptions): GuestOnlyRouteMiddleware {
- return defineNuxtRouteMiddleware(async (to) => {
- if (!matchesRoutes(options.routes, to.path)) {
- return undefined
- }
-
- const currentAuth = await useAuth()
- if (!currentAuth.authenticated.value) {
- return undefined
- }
-
- return navigateTo(options.redirectTo, {
- redirectCode: options.status ?? 303,
- })
- })
-}
-
-export const routeProtectionInternals = {
- matchesRoute,
- matchesRoutes,
-}
diff --git a/packages/adapter-nuxt/src/runtime/shims.d.ts b/packages/adapter-nuxt/src/runtime/shims.d.ts
index bb6aa19..173ca43 100644
--- a/packages/adapter-nuxt/src/runtime/shims.d.ts
+++ b/packages/adapter-nuxt/src/runtime/shims.d.ts
@@ -34,51 +34,18 @@ interface HoloRuntimeConfig extends RuntimeConfigInput {
}
}
-interface HoloRef {
- value: TValue
-}
-
-interface HoloComputedRef {
- readonly value: TValue
-}
-
-interface HoloUseFetchResult {
- readonly data: HoloRef
- readonly refresh: () => Promise
-}
-
-interface HoloRouteLocation {
- readonly path: string
-}
-
-type HoloNavigateToResult = void | false | Promise
-
/**
* Minimal Nuxt runtime shims for adapter typechecking.
*
* Keep these declarations limited to the fields consumed by adapter runtime code:
- * composables/auth.ts uses HoloUseFetchResult.data/refresh, useFetch options.key,
- * useState key/init with HoloRef.value, and computed getter/value.
+ * runtime config, storage access, and Nitro route/plugin globals.
*/
declare module '#app' {
export function useRuntimeConfig(): HoloRuntimeConfig
}
declare module '#imports' {
- export function computed(getter: () => TValue): HoloComputedRef
- export function defineNuxtRouteMiddleware(
- middleware: (to: HoloRouteLocation, from: HoloRouteLocation) => TValue | Promise,
- ): (to: HoloRouteLocation, from: HoloRouteLocation) => TValue | Promise
- export function navigateTo(
- to: string,
- options?: { readonly redirectCode?: number },
- ): HoloNavigateToResult
- export function useFetch(
- request: string,
- options?: { readonly key?: string },
- ): Promise>
export function useRuntimeConfig(): HoloRuntimeConfig
- export function useState(key: string, init: () => TValue): HoloRef
export function useStorage(base: string): unknown
}
diff --git a/packages/adapter-nuxt/tests/client.test.ts b/packages/adapter-nuxt/tests/client.test.ts
index 3bf655c..bdc3d96 100644
--- a/packages/adapter-nuxt/tests/client.test.ts
+++ b/packages/adapter-nuxt/tests/client.test.ts
@@ -8,156 +8,6 @@ describe('@holo-js/adapter-nuxt client', () => {
vi.doUnmock('#imports')
})
- it('exposes current user state through the auth composable', async () => {
- const state = new Map()
- const currentAuth = {
- value: {
- authenticated: true,
- guard: 'web',
- user: {
- id: 1,
- email: 'ava@example.com',
- name: 'Ava',
- },
- },
- }
-
- vi.doMock('#imports', () => ({
- computed(getter: () => TValue) {
- return {
- get value() {
- return getter()
- },
- }
- },
- useFetch: vi.fn(async () => ({
- data: currentAuth,
- async refresh() {
- currentAuth.value = {
- authenticated: true,
- guard: 'web',
- user: {
- id: 2,
- email: 'nora@example.com',
- name: 'Nora',
- },
- }
- },
- })),
- useState(key: string, init: () => TValue) {
- let ref = state.get(key)
- if (!ref) {
- ref = { value: init() }
- state.set(key, ref)
- }
-
- return ref as { value: TValue }
- },
- }))
-
- const { useAuth } = await import('../src/runtime/composables/auth')
- const auth = await useAuth()
-
- expect(auth.authenticated.value).toBe(true)
- expect(auth.user.value?.email).toBe('ava@example.com')
- await expect(auth.refreshUser()).resolves.toMatchObject({
- email: 'nora@example.com',
- })
- expect(auth.user.value?.email).toBe('nora@example.com')
- })
-
- it('adds guard query parameters for relative and absolute current-user endpoints', async () => {
- const requestedUrls: string[] = []
-
- vi.doMock('#imports', () => ({
- computed(getter: () => TValue) {
- return {
- get value() {
- return getter()
- },
- }
- },
- useFetch: vi.fn(async (url: string) => {
- requestedUrls.push(url)
-
- return {
- data: {
- value: {
- authenticated: false,
- guard: 'admin',
- user: null,
- },
- },
- async refresh() {},
- }
- }),
- useState(_key: string, init: () => TValue) {
- return { value: init() }
- },
- }))
-
- const { useAuth } = await import('../src/runtime/composables/auth')
-
- await useAuth({ endpoint: '/custom/auth/user', guard: 'admin' })
- await useAuth({ endpoint: 'https://example.com/auth/user', guard: 'admin' })
-
- expect(requestedUrls).toEqual([
- '/custom/auth/user?guard=admin',
- 'https://example.com/auth/user?guard=admin',
- ])
- })
-
- it('clears the current user when a refresh has no current auth payload', async () => {
- const currentAuth: {
- value?: {
- readonly authenticated: boolean
- readonly guard: string
- readonly user: {
- readonly id: number
- readonly email: string
- readonly name: string
- } | null
- }
- } = {
- value: {
- authenticated: true,
- guard: 'web',
- user: {
- id: 1,
- email: 'ava@example.com',
- name: 'Ava',
- },
- },
- }
-
- vi.doMock('#imports', () => ({
- computed(getter: () => TValue) {
- return {
- get value() {
- return getter()
- },
- }
- },
- useFetch: vi.fn(async () => ({
- data: currentAuth,
- async refresh() {
- currentAuth.value = undefined
- },
- })),
- useState(_key: string, init: () => TValue) {
- return { value: init() }
- },
- }))
-
- const { useAuth } = await import('../src/runtime/composables/auth')
- const auth = await useAuth()
-
- expect(auth.authenticated.value).toBe(true)
- await expect(auth.refreshUser()).resolves.toBeNull()
- expect(auth.user.value).toBeNull()
- expect(auth.authenticated.value).toBe(false)
- })
-
it('wraps the shared form client in a Vue-friendly reactive proxy', async () => {
;(globalThis as unknown as {
__holoNuxtClientDisposed?: boolean
diff --git a/packages/adapter-nuxt/tests/module.test.ts b/packages/adapter-nuxt/tests/module.test.ts
index 3c4e972..e130a9b 100644
--- a/packages/adapter-nuxt/tests/module.test.ts
+++ b/packages/adapter-nuxt/tests/module.test.ts
@@ -335,10 +335,9 @@ export default defineDatabaseConfig({
})
expect(addServerPlugin).toHaveBeenCalledWith('./runtime/plugins/init')
expect(addImports).toHaveBeenCalledTimes(1)
- expect(addImports.mock.calls[0]?.[0]).toHaveLength(7)
+ expect(addImports.mock.calls[0]?.[0]).toHaveLength(6)
expect(addImports.mock.calls[0]?.[0]).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'holo', as: 'holo', from: '@holo-js/adapter-nuxt/runtime' }),
- expect.objectContaining({ name: 'useAuth', as: 'useAuth', from: '@holo-js/adapter-nuxt/auth' }),
expect.objectContaining({ name: 'useStorage', as: 'useStorage', from: '@holo-js/adapter-nuxt/storage' }),
expect.objectContaining({ name: 'Storage', as: 'Storage', from: '@holo-js/adapter-nuxt/storage' }),
]))
diff --git a/packages/adapter-nuxt/tests/package.test.ts b/packages/adapter-nuxt/tests/package.test.ts
index 022b817..9d01f73 100644
--- a/packages/adapter-nuxt/tests/package.test.ts
+++ b/packages/adapter-nuxt/tests/package.test.ts
@@ -5,34 +5,50 @@ import { describe, expect, it } from 'vitest'
describe('@holo-js/adapter-nuxt package boundaries', () => {
it('keeps exported storage and forms surfaces as optional peers', async () => {
const packageJsonPath = resolve(import.meta.dirname, '../package.json')
+ const moduleEntryPath = resolve(import.meta.dirname, '../src/module.ts')
const runtimeEntryPath = resolve(import.meta.dirname, '../src/runtime/composables/index.ts')
- const authEntryPath = resolve(import.meta.dirname, '../src/runtime/composables/auth.ts')
const storageEntryPath = resolve(import.meta.dirname, '../src/runtime/composables/storage.ts')
const clientEntryPath = resolve(import.meta.dirname, '../src/runtime/composables/forms.ts')
+ const storagePluginPath = resolve(import.meta.dirname, '../src/runtime/plugins/storage.ts')
+ const storageRoutePath = resolve(import.meta.dirname, '../src/runtime/server/routes/storage.get.ts')
+ const s3DriverPath = resolve(import.meta.dirname, '../src/runtime/drivers/s3.ts')
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
dependencies?: Record
peerDependencies?: Record
peerDependenciesMeta?: Record
exports?: Record
}
+ const moduleEntry = await readFile(moduleEntryPath, 'utf8')
const runtimeEntry = await readFile(runtimeEntryPath, 'utf8')
- const authEntry = await readFile(authEntryPath, 'utf8')
const storageEntry = await readFile(storageEntryPath, 'utf8')
const clientEntry = await readFile(clientEntryPath, 'utf8')
+ const storagePlugin = await readFile(storagePluginPath, 'utf8')
+ const storageRoute = await readFile(storageRoutePath, 'utf8')
+ const s3Driver = await readFile(s3DriverPath, 'utf8')
- expect(runtimeEntry).not.toContain("@holo-js/storage/runtime")
- expect(storageEntry).toContain("@holo-js/storage/runtime")
- expect(clientEntry).toContain("@holo-js/forms/client")
- expect(authEntry).toContain("@holo-js/auth")
+ expect(moduleEntry).not.toMatch(/@holo-js\/forms/)
+ expect(moduleEntry).toMatch(/import\(\s*['"]@holo-js\/storage['"]\s*\)/)
+ expect(moduleEntry).toMatch(/import\(\s*['"]@holo-js\/storage-s3['"](?:\s+as\s+string)?\s*\)/)
+ expect(runtimeEntry).not.toMatch(/@holo-js\/forms/)
+ expect(runtimeEntry).not.toMatch(/@holo-js\/storage\/runtime/)
+ expect(storageEntry).toMatch(/@holo-js\/storage\/runtime/)
+ expect(clientEntry).toMatch(/@holo-js\/forms\/client/)
+ expect(storagePlugin).toMatch(/@holo-js\/storage\/runtime/)
+ expect(storageRoute).toMatch(/@holo-js\/storage/)
+ expect(s3Driver).toMatch(/@holo-js\/storage-s3/)
expect(packageJson.exports?.['./storage']).toBeDefined()
- expect(packageJson.exports?.['./auth']).toBeDefined()
- expect(packageJson.exports?.['./server']).toBeDefined()
- expect(packageJson.dependencies?.['@holo-js/auth']).toBeDefined()
+ expect(packageJson.exports?.['./auth']).toBeUndefined()
+ expect(packageJson.exports?.['./server']).toBeUndefined()
+ expect(packageJson.dependencies?.['@holo-js/auth']).toBeUndefined()
+ expect(packageJson.peerDependencies?.['@holo-js/auth']).toBeUndefined()
expect(packageJson.dependencies?.['@holo-js/storage']).toBeUndefined()
+ expect(packageJson.dependencies?.['@holo-js/storage-s3']).toBeUndefined()
expect(packageJson.dependencies?.['@holo-js/forms']).toBeUndefined()
expect(packageJson.peerDependencies?.['@holo-js/storage']).toBeDefined()
+ expect(packageJson.peerDependencies?.['@holo-js/storage-s3']).toBeDefined()
expect(packageJson.peerDependencies?.['@holo-js/forms']).toBeDefined()
expect(packageJson.peerDependenciesMeta?.['@holo-js/storage']?.optional).toBe(true)
+ expect(packageJson.peerDependenciesMeta?.['@holo-js/storage-s3']?.optional).toBe(true)
expect(packageJson.peerDependenciesMeta?.['@holo-js/forms']?.optional).toBe(true)
})
})
diff --git a/packages/adapter-nuxt/tests/protection.test.ts b/packages/adapter-nuxt/tests/protection.test.ts
deleted file mode 100644
index 88295d2..0000000
--- a/packages/adapter-nuxt/tests/protection.test.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest'
-
-describe('@holo-js/adapter-nuxt route protection', () => {
- afterEach(() => {
- vi.resetModules()
- vi.doUnmock('#imports')
- vi.doUnmock('../src/runtime/composables/auth')
- })
-
- it('redirects authenticated users from guest-only route middleware', async () => {
- const navigateTo = vi.fn((to: string, options?: { readonly redirectCode?: number }) => ({ to, options }))
- vi.doMock('#imports', () => ({
- defineNuxtRouteMiddleware: (middleware: unknown) => middleware,
- navigateTo,
- }))
- vi.doMock('../src/runtime/composables/auth', () => ({
- useAuth: vi.fn(async () => ({
- authenticated: { value: true },
- })),
- }))
-
- const { guestOnly } = await import('../src/runtime/server/protection')
- const middleware = guestOnly({
- routes: ['/login', '/register', '/auth/*'],
- redirectTo: '/admin',
- })
-
- await expect(middleware({ path: '/login' }, { path: '/' })).resolves.toEqual({
- to: '/admin',
- options: { redirectCode: 303 },
- })
- })
-
- it('continues for guest-only route middleware when no user is authenticated', async () => {
- vi.doMock('#imports', () => ({
- defineNuxtRouteMiddleware: (middleware: unknown) => middleware,
- navigateTo: vi.fn(),
- }))
- vi.doMock('../src/runtime/composables/auth', () => ({
- useAuth: vi.fn(async () => ({
- authenticated: { value: false },
- })),
- }))
-
- const { guestOnly } = await import('../src/runtime/server/protection')
- const middleware = guestOnly({
- routes: ['/login'],
- redirectTo: '/admin',
- })
-
- await expect(middleware({ path: '/login' }, { path: '/' })).resolves.toBeUndefined()
- })
-
- it('supports wildcard route matching for guest-only route middleware', async () => {
- vi.doMock('#imports', () => ({
- defineNuxtRouteMiddleware: (middleware: unknown) => middleware,
- navigateTo: vi.fn(),
- }))
-
- const { routeProtectionInternals } = await import('../src/runtime/server/protection')
-
- expect(routeProtectionInternals.matchesRoutes(['/auth/*'], '/auth')).toBe(true)
- expect(routeProtectionInternals.matchesRoutes(['/auth/*'], '/auth/reset')).toBe(true)
- expect(routeProtectionInternals.matchesRoutes(['/auth/*'], '/login')).toBe(false)
-
- const statefulRoute = /^\/auth/g
- expect(routeProtectionInternals.matchesRoutes([statefulRoute], '/auth')).toBe(true)
- expect(routeProtectionInternals.matchesRoutes([statefulRoute], '/auth')).toBe(true)
- })
-})
diff --git a/packages/adapter-nuxt/tests/setup.test.ts b/packages/adapter-nuxt/tests/setup.test.ts
index 3c9e5b9..689ed5d 100644
--- a/packages/adapter-nuxt/tests/setup.test.ts
+++ b/packages/adapter-nuxt/tests/setup.test.ts
@@ -421,10 +421,9 @@ export default defineStorageConfig({
expect(getHoloStorageRuntimeConfig(nuxt)?.routePrefix).toBe('/files')
expect(nuxt.options.build.transpile).toContain('./runtime')
expect(addImports).toHaveBeenCalledTimes(1)
- expect(addImports.mock.calls[0]?.[0]).toHaveLength(7)
+ expect(addImports.mock.calls[0]?.[0]).toHaveLength(6)
expect(addImports.mock.calls[0]?.[0]).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'holo', as: 'holo', from: '@holo-js/adapter-nuxt/runtime' }),
- expect.objectContaining({ name: 'useAuth', as: 'useAuth', from: '@holo-js/adapter-nuxt/auth' }),
expect.objectContaining({ name: 'useStorage', as: 'useStorage', from: '@holo-js/adapter-nuxt/storage' }),
expect.objectContaining({ name: 'Storage', as: 'Storage', from: '@holo-js/adapter-nuxt/storage' }),
]))
diff --git a/packages/adapter-nuxt/tsconfig.json b/packages/adapter-nuxt/tsconfig.json
index 94826dd..4c463e7 100644
--- a/packages/adapter-nuxt/tsconfig.json
+++ b/packages/adapter-nuxt/tsconfig.json
@@ -11,8 +11,6 @@
"nitropack/runtime/context": ["./src/runtime/shims.d.ts"],
"nitropack/runtime/plugin": ["./src/runtime/shims.d.ts"],
"nitropack/runtime/storage": ["./src/runtime/shims.d.ts"],
- "@holo-js/auth": ["../auth/src/index.ts"],
- "@holo-js/auth/client": ["../auth/src/client.ts"],
"@holo-js/forms": ["../forms/src/index.ts"],
"@holo-js/forms/client": ["../forms/src/client.ts"],
"@holo-js/security": ["../security/src/index.ts"],
diff --git a/packages/adapter-nuxt/vitest.config.ts b/packages/adapter-nuxt/vitest.config.ts
index 694a128..ea7f542 100644
--- a/packages/adapter-nuxt/vitest.config.ts
+++ b/packages/adapter-nuxt/vitest.config.ts
@@ -4,11 +4,6 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
alias: {
- '@holo-js/auth/client': resolve(__dirname, '../auth/src/client.ts'),
- '@holo-js/auth': resolve(__dirname, '../auth/src/index.ts'),
- '@holo-js/auth-social': resolve(__dirname, '../auth-social/src/index.ts'),
- '@holo-js/auth-workos': resolve(__dirname, '../auth-workos/src/index.ts'),
- '@holo-js/auth-clerk': resolve(__dirname, '../auth-clerk/src/index.ts'),
'@holo-js/core/runtime': resolve(__dirname, '../core/src/portable/index.ts'),
'@holo-js/storage/runtime/drivers/s3': resolve(__dirname, '../storage/src/runtime/drivers/s3.ts'),
'@holo-js/storage/runtime': resolve(__dirname, '../storage/src/runtime/composables/index.ts'),
diff --git a/packages/adapter-sveltekit/package.json b/packages/adapter-sveltekit/package.json
index b547f9a..32284a0 100644
--- a/packages/adapter-sveltekit/package.json
+++ b/packages/adapter-sveltekit/package.json
@@ -15,11 +15,6 @@
"import": "./dist/client.mjs",
"default": "./dist/client.mjs"
},
- "./server": {
- "types": "./dist/server.d.ts",
- "import": "./dist/server.mjs",
- "default": "./dist/server.mjs"
- },
"./transport": {
"types": "./dist/transport.d.ts",
"import": "./dist/transport.mjs",
@@ -38,7 +33,6 @@
"test": "vitest --run"
},
"dependencies": {
- "@holo-js/auth": "^0.1.4",
"@holo-js/config": "^0.1.4",
"@holo-js/core": "^0.1.4",
"svelte": "catalog:"
diff --git a/packages/adapter-sveltekit/src/client.ts b/packages/adapter-sveltekit/src/client.ts
index 80b08cb..ab4dbe2 100644
--- a/packages/adapter-sveltekit/src/client.ts
+++ b/packages/adapter-sveltekit/src/client.ts
@@ -1,7 +1,4 @@
-import { getContext, setContext } from 'svelte'
import { createSubscriber } from 'svelte/reactivity'
-import { refreshUser as refreshCurrentUser } from '@holo-js/auth/client'
-import type { AuthClientRequestOptions, HoloAuthUser } from '@holo-js/auth/client'
import type { FormSchema, InferFormData } from '@holo-js/forms'
import {
type InferFormFieldTree,
@@ -19,117 +16,6 @@ export {
type UseFormResult,
type ValidateOnMode,
} from '@holo-js/forms/client'
-export type { HoloAuthUser } from '@holo-js/auth/client'
-
-export type UseAuthOptions = AuthClientRequestOptions & {
- readonly initialUser?: HoloAuthUser | null
-}
-
-export type UseAuthResult = {
- readonly authenticated: boolean
- readonly user: HoloAuthUser | null
- readonly refreshUser: () => Promise
-}
-
-const authContextKey = Symbol('holo-js.auth.client')
-
-export function setAuthContext(auth: UseAuthResult): UseAuthResult {
- setContext(authContextKey, auth)
- return auth
-}
-
-export function getAuthContext(): UseAuthResult | undefined {
- return getContext(authContextKey)
-}
-
-function tryGetAuthContext(): UseAuthResult | undefined {
- try {
- return getAuthContext()
- } catch {
- return undefined
- }
-}
-
-function trySetAuthContext(auth: UseAuthResult): void {
- try {
- setAuthContext(auth)
- } catch {
- // Outside component initialization there is no Svelte context to attach.
- }
-}
-
-class AuthClientState implements UseAuthResult {
- #notify: () => void = () => {}
- #pendingRefresh: Promise | undefined
- #user: HoloAuthUser | null
-
- readonly #subscribe = createSubscriber((update) => {
- this.#notify = update
-
- return () => {
- this.#notify = () => {}
- }
- })
-
- constructor(
- initialUser: HoloAuthUser | null,
- private requestOptions: AuthClientRequestOptions,
- ) {
- this.#user = initialUser
- }
-
- get authenticated(): boolean {
- this.#subscribe()
- return this.#user !== null
- }
-
- get user(): HoloAuthUser | null {
- this.#subscribe()
- return this.#user
- }
-
- async refreshUser(): Promise {
- if (this.#pendingRefresh) {
- return this.#pendingRefresh
- }
-
- const refresh = refreshCurrentUser(this.requestOptions)
- .then((user) => {
- this.#user = user
- this.#notify()
-
- return user
- })
- .finally(() => {
- this.#pendingRefresh = undefined
- })
-
- this.#pendingRefresh = refresh
- return refresh
- }
-
- setRequestOptions(requestOptions: AuthClientRequestOptions): void {
- this.requestOptions = requestOptions
- }
-}
-
-export function useAuth(options?: UseAuthOptions): UseAuthResult {
- const context = tryGetAuthContext()
- const resolvedOptions = options ?? {}
- const { initialUser = null, ...requestOptions } = resolvedOptions
- if (context && typeof options?.initialUser === 'undefined') {
- if (context instanceof AuthClientState) {
- context.setRequestOptions(requestOptions)
- }
-
- return context
- }
-
- const auth = new AuthClientState(initialUser, requestOptions)
-
- trySetAuthContext(auth)
- return auth
-}
function isPlainObject(value: unknown): value is Record {
return !!value
diff --git a/packages/adapter-sveltekit/tests/adapter.type.test.ts b/packages/adapter-sveltekit/tests/adapter.type.test.ts
index f25095a..5ab5c50 100644
--- a/packages/adapter-sveltekit/tests/adapter.type.test.ts
+++ b/packages/adapter-sveltekit/tests/adapter.type.test.ts
@@ -155,8 +155,9 @@ describe('@holo-js/adapter-sveltekit typing', () => {
await writeFile(
entryPath,
[
- `import { useAuth, useForm, type HoloAuthUser, type UseAuthResult } from '@holo-js/adapter-sveltekit/client'`,
- `import { auth } from '@holo-js/adapter-sveltekit/server'`,
+ `import { useAuth, type HoloAuthUser, type UseAuthResult } from '@holo-js/auth/sveltekit/client'`,
+ `import { auth } from '@holo-js/auth/sveltekit/server'`,
+ `import { useForm } from '@holo-js/adapter-sveltekit/client'`,
`const currentAuth = useAuth()`,
`const user: HoloAuthUser | null = currentAuth.user`,
`const authResult: UseAuthResult = currentAuth`,
diff --git a/packages/adapter-sveltekit/tests/client.test.ts b/packages/adapter-sveltekit/tests/client.test.ts
index dff0242..310d7e4 100644
--- a/packages/adapter-sveltekit/tests/client.test.ts
+++ b/packages/adapter-sveltekit/tests/client.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { field, schema } from '@holo-js/forms'
const subscriberCleanups: Array<() => void> = []
@@ -26,219 +26,13 @@ vi.mock('svelte/reactivity', () => ({
}))
describe('@holo-js/adapter-sveltekit client', () => {
- beforeEach(() => {
- vi.doMock('@holo-js/auth/client', () => ({
- refreshUser: vi.fn(async () => null),
- }))
- })
-
afterEach(() => {
cleanupSubscribers()
vi.resetModules()
vi.clearAllMocks()
- vi.doUnmock('@holo-js/auth/client')
vi.doUnmock('svelte')
})
- it('exposes current user state through the auth client helper', async () => {
- const refreshedUser = {
- id: 2,
- email: 'nora@example.com',
- name: 'Nora',
- }
-
- vi.doMock('@holo-js/auth/client', () => ({
- refreshUser: vi.fn(async () => refreshedUser),
- }))
-
- const { useAuth } = await import('../src/client')
- const auth = useAuth({
- initialUser: {
- id: 1,
- email: 'ava@example.com',
- name: 'Ava',
- },
- })
-
- expect(auth.authenticated).toBe(true)
- expect(auth.user?.email).toBe('ava@example.com')
- cleanupSubscribers()
- await expect(auth.refreshUser()).resolves.toEqual(refreshedUser)
- expect(auth.user).toEqual(refreshedUser)
- })
-
- it('shares auth state through Svelte context when called without options', async () => {
- let contextValue: unknown
-
- vi.doMock('svelte', () => ({
- getContext: vi.fn(() => contextValue),
- setContext: vi.fn((_key: symbol, value: unknown) => {
- contextValue = value
- return value
- }),
- }))
- vi.doMock('@holo-js/auth/client', () => ({
- refreshUser: vi.fn(async () => null),
- }))
-
- const { useAuth } = await import('../src/client')
- const auth = useAuth({
- initialUser: {
- id: 1,
- email: 'ava@example.com',
- name: 'Ava',
- },
- })
-
- expect(useAuth()).toBe(auth)
- })
-
- it('reuses context when request options omit an initial user', async () => {
- const initialUser = {
- id: 1,
- email: 'ava@example.com',
- name: 'Ava',
- }
- const refreshUser = vi.fn(async () => initialUser)
- let contextValue: unknown
-
- vi.doMock('svelte', () => ({
- getContext: vi.fn(() => contextValue),
- setContext: vi.fn((_key: symbol, value: unknown) => {
- contextValue = value
- return value
- }),
- }))
- vi.doMock('@holo-js/auth/client', () => ({
- refreshUser,
- }))
-
- const { useAuth } = await import('../src/client')
- const auth = useAuth({
- guard: 'web',
- initialUser,
- })
- const reusedAuth = useAuth({ guard: 'admin' })
-
- expect(reusedAuth).toBe(auth)
- await expect(reusedAuth.refreshUser()).resolves.toEqual(initialUser)
- expect(refreshUser).toHaveBeenCalledWith({ guard: 'admin' })
- })
-
- it('reuses manually provided auth context without mutating request options', async () => {
- const manualAuth = {
- authenticated: true,
- user: {
- id: 1,
- email: 'ava@example.com',
- name: 'Ava',
- },
- refreshUser: vi.fn(async () => null),
- }
- let contextValue: unknown
-
- vi.doMock('svelte', () => ({
- getContext: vi.fn(() => contextValue),
- setContext: vi.fn((_key: symbol, value: unknown) => {
- contextValue = value
- return value
- }),
- }))
-
- const { setAuthContext, useAuth } = await import('../src/client')
- setAuthContext(manualAuth)
-
- expect(useAuth({ guard: 'admin' })).toBe(manualAuth)
- })
-
- it('creates a fresh auth state when an initial user is explicitly provided', async () => {
- let contextValue: unknown
-
- vi.doMock('svelte', () => ({
- getContext: vi.fn(() => contextValue),
- setContext: vi.fn((_key: symbol, value: unknown) => {
- contextValue = value
- return value
- }),
- }))
-
- const { useAuth } = await import('../src/client')
- const initialAuth = useAuth({
- initialUser: {
- id: 1,
- email: 'ava@example.com',
- name: 'Ava',
- },
- })
- const replacedAuth = useAuth({
- initialUser: {
- id: 2,
- email: 'nora@example.com',
- name: 'Nora',
- },
- })
-
- expect(replacedAuth).not.toBe(initialAuth)
- expect(replacedAuth.user?.email).toBe('nora@example.com')
- })
-
- it('coalesces concurrent auth refresh requests', async () => {
- const refreshedUser = {
- id: 2,
- email: 'nora@example.com',
- name: 'Nora',
- }
- let resolveRefresh: (user: typeof refreshedUser) => void = () => {}
- const refreshUser = vi.fn(() => new Promise((resolve) => {
- resolveRefresh = resolve
- }))
-
- vi.doMock('@holo-js/auth/client', () => ({
- refreshUser,
- }))
-
- const { useAuth } = await import('../src/client')
- const auth = useAuth({ initialUser: null })
- const firstRefresh = auth.refreshUser()
- const secondRefresh = auth.refreshUser()
-
- expect(refreshUser).toHaveBeenCalledTimes(1)
- resolveRefresh(refreshedUser)
- await expect(firstRefresh).resolves.toEqual(refreshedUser)
- await expect(secondRefresh).resolves.toEqual(refreshedUser)
- expect(auth.user).toEqual(refreshedUser)
- })
-
- it('allows refresh before Svelte subscribes to auth state', async () => {
- vi.doMock('@holo-js/auth/client', () => ({
- refreshUser: vi.fn(async () => null),
- }))
-
- const { useAuth } = await import('../src/client')
- const auth = useAuth({
- initialUser: {
- id: 1,
- email: 'ava@example.com',
- name: 'Ava',
- },
- })
-
- await expect(auth.refreshUser()).resolves.toBeNull()
- expect(auth.user).toBeNull()
- })
-
- it('creates guest auth state when no options are provided', async () => {
- vi.doMock('@holo-js/auth/client', () => ({
- refreshUser: vi.fn(async () => null),
- }))
-
- const { useAuth } = await import('../src/client')
- const auth = useAuth()
-
- expect(auth.authenticated).toBe(false)
- expect(auth.user).toBeNull()
- })
-
it('wraps the shared form client with a Svelte reactive subscriber bridge', async () => {
const { useForm } = await import('../src/client')
const login = schema({
diff --git a/packages/adapter-sveltekit/tests/package.test.ts b/packages/adapter-sveltekit/tests/package.test.ts
index 6881f74..f9c8ec3 100644
--- a/packages/adapter-sveltekit/tests/package.test.ts
+++ b/packages/adapter-sveltekit/tests/package.test.ts
@@ -3,19 +3,26 @@ import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
describe('@holo-js/adapter-sveltekit package boundaries', () => {
- it('keeps forms and auth client surfaces on the correct dependency boundaries', async () => {
+ it('keeps forms client surface optional and leaves auth helpers in @holo-js/auth', async () => {
const packageJsonPath = resolve(import.meta.dirname, '../package.json')
+ const indexEntryPath = resolve(import.meta.dirname, '../src/index.ts')
const clientEntryPath = resolve(import.meta.dirname, '../src/client.ts')
+ const transportEntryPath = resolve(import.meta.dirname, '../src/transport.ts')
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
dependencies?: Record
peerDependencies?: Record
peerDependenciesMeta?: Record
}
+ const indexEntry = await readFile(indexEntryPath, 'utf8')
const clientEntry = await readFile(clientEntryPath, 'utf8')
+ const transportEntry = await readFile(transportEntryPath, 'utf8')
+ expect(indexEntry).not.toContain("@holo-js/forms")
expect(clientEntry).toContain("@holo-js/forms/client")
- expect(clientEntry).toContain("@holo-js/auth/client")
- expect(packageJson.dependencies?.['@holo-js/auth']).toBeDefined()
+ expect(clientEntry).not.toContain("@holo-js/auth")
+ expect(transportEntry).not.toContain("@holo-js/forms")
+ expect(transportEntry).not.toContain("@holo-js/auth")
+ expect(packageJson.dependencies?.['@holo-js/auth']).toBeUndefined()
expect(packageJson.peerDependencies?.['@holo-js/auth']).toBeUndefined()
expect(packageJson.dependencies?.['@holo-js/forms']).toBeUndefined()
expect(packageJson.peerDependencies?.['@holo-js/forms']).toBeDefined()
diff --git a/packages/adapter-sveltekit/tests/server.test.ts b/packages/adapter-sveltekit/tests/server.test.ts
deleted file mode 100644
index 8064b41..0000000
--- a/packages/adapter-sveltekit/tests/server.test.ts
+++ /dev/null
@@ -1,183 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest'
-
-const authProviderMarker = Symbol.for('holo-js.auth.provider')
-
-describe('@holo-js/adapter-sveltekit server auth', () => {
- afterEach(() => {
- vi.restoreAllMocks()
- vi.resetModules()
- vi.doUnmock('@holo-js/auth')
- })
-
- it('returns a serializable current user without auth runtime symbol metadata', async () => {
- const runtimeUser = {
- id: 1,
- email: 'ava@example.com',
- name: 'Ava',
- }
- Object.defineProperty(runtimeUser, authProviderMarker, {
- value: 'users',
- })
-
- vi.doMock('@holo-js/auth', () => ({
- default: {
- guard: vi.fn(),
- },
- user: vi.fn(async () => runtimeUser),
- }))
-
- const { auth } = await import('../src/server')
- const currentAuth = await auth()
-
- expect(currentAuth.authenticated).toBe(true)
- expect(currentAuth.user).toEqual(runtimeUser)
- expect(currentAuth.user).not.toBe(runtimeUser)
- expect(Object.getOwnPropertySymbols(currentAuth.user!)).not.toContain(authProviderMarker)
- })
-
- it('returns a guest auth state when no user is authenticated', async () => {
- vi.doMock('@holo-js/auth', () => ({
- default: {
- guard: vi.fn(),
- },
- user: vi.fn(async () => null),
- }))
-
- const { auth } = await import('../src/server')
- const currentAuth = await auth()
-
- expect(currentAuth).toEqual({
- authenticated: false,
- user: null,
- })
- })
-
- it('resolves named guards through the auth facade', async () => {
- const guardUser = {
- id: 2,
- email: 'admin@example.com',
- name: 'Admin',
- }
- const guard = vi.fn(() => ({
- user: vi.fn(async () => guardUser),
- }))
-
- vi.doMock('@holo-js/auth', () => ({
- default: {
- guard,
- },
- user: vi.fn(),
- }))
-
- const { auth } = await import('../src/server')
- const currentAuth = await auth({ guard: 'admin' })
-
- expect(guard).toHaveBeenCalledWith('admin')
- expect(currentAuth.user).toEqual(guardUser)
- })
-
- it('returns a guest auth state when auth resolution fails', async () => {
- vi.spyOn(console, 'warn').mockImplementation(() => {})
- vi.doMock('@holo-js/auth', () => ({
- default: {
- guard: vi.fn(),
- },
- user: vi.fn(async () => {
- throw new Error('auth unavailable')
- }),
- }))
-
- const { auth } = await import('../src/server')
- const currentAuth = await auth()
-
- expect(currentAuth).toEqual({
- authenticated: false,
- user: null,
- })
- expect(console.warn).toHaveBeenCalled()
- })
-
- it('redirects authenticated users from guest-only hook routes', async () => {
- vi.doMock('@holo-js/auth', () => ({
- default: {
- guard: vi.fn(),
- },
- user: vi.fn(async () => ({ id: 1, email: 'ava@example.com' })),
- }))
-
- const { guestOnly } = await import('../src/server')
- const handle = guestOnly({
- routes: ['/login', '/register', '/auth/*'],
- redirectTo: '/admin',
- })
- const response = await handle({
- event: {
- url: new URL('https://app.test/login'),
- },
- resolve: vi.fn(async () => new Response('ok')),
- })
-
- expect(response.status).toBe(303)
- expect(response.headers.get('location')).toBe('https://app.test/admin')
- })
-
- it('resolves guest-only hook routes when no user is authenticated', async () => {
- vi.doMock('@holo-js/auth', () => ({
- default: {
- guard: vi.fn(),
- },
- user: vi.fn(async () => null),
- }))
-
- const { guestOnly } = await import('../src/server')
- const resolved = new Response('ok')
- const resolve = vi.fn(async () => resolved)
- const handle = guestOnly({
- routes: ['/login'],
- redirectTo: '/admin',
- })
-
- await expect(handle({
- event: {
- url: new URL('https://app.test/login'),
- },
- resolve,
- })).resolves.toBe(resolved)
- })
-
- it('resolves guest-only hook routes when the redirect target is the current URL', async () => {
- vi.doMock('@holo-js/auth', () => ({
- default: {
- guard: vi.fn(),
- },
- user: vi.fn(async () => ({ id: 1, email: 'ava@example.com' })),
- }))
-
- const { guestOnly } = await import('../src/server')
- const resolved = new Response('ok')
- const resolve = vi.fn(async () => resolved)
- const handle = guestOnly({
- routes: ['/login'],
- redirectTo: '/login?next=%2Fadmin',
- })
-
- await expect(handle({
- event: {
- url: new URL('https://app.test/login?next=%2Fadmin'),
- },
- resolve,
- })).resolves.toBe(resolved)
- })
-
- it('supports wildcard route matching for guest-only hook routes', async () => {
- const { routeProtectionInternals } = await import('../src/server')
-
- expect(routeProtectionInternals.matchesRoutes(['/auth/*'], '/auth')).toBe(true)
- expect(routeProtectionInternals.matchesRoutes(['/auth/*'], '/auth/reset')).toBe(true)
- expect(routeProtectionInternals.matchesRoutes(['/auth/*'], '/login')).toBe(false)
-
- const statefulRoute = /^\/auth/g
- expect(routeProtectionInternals.matchesRoutes([statefulRoute], '/auth')).toBe(true)
- expect(routeProtectionInternals.matchesRoutes([statefulRoute], '/auth')).toBe(true)
- })
-})
diff --git a/packages/adapter-sveltekit/tsconfig.json b/packages/adapter-sveltekit/tsconfig.json
index d661263..dc585df 100644
--- a/packages/adapter-sveltekit/tsconfig.json
+++ b/packages/adapter-sveltekit/tsconfig.json
@@ -4,11 +4,6 @@
"outDir": "dist",
"baseUrl": ".",
"paths": {
- "@holo-js/auth": ["../auth/src/index.ts"],
- "@holo-js/auth/client": ["../auth/src/client.ts"],
- "@holo-js/auth-clerk": ["../auth-clerk/src/index.ts"],
- "@holo-js/auth-social": ["../auth-social/src/index.ts"],
- "@holo-js/auth-workos": ["../auth-workos/src/index.ts"],
"@holo-js/config": ["../config/src/index.ts"],
"@holo-js/core": ["../core/src/index.ts"],
"@holo-js/forms": ["../forms/src/index.ts"],
diff --git a/packages/adapter-sveltekit/tsup.config.ts b/packages/adapter-sveltekit/tsup.config.ts
index ea0a5da..9103ddf 100644
--- a/packages/adapter-sveltekit/tsup.config.ts
+++ b/packages/adapter-sveltekit/tsup.config.ts
@@ -6,7 +6,6 @@ export default defineConfig({
entry: {
index: 'src/index.ts',
client: 'src/client.ts',
- server: 'src/server.ts',
transport: 'src/transport.ts',
},
format: ['esm'],
diff --git a/packages/adapter-sveltekit/vitest.config.ts b/packages/adapter-sveltekit/vitest.config.ts
index 0174e00..dabc49c 100644
--- a/packages/adapter-sveltekit/vitest.config.ts
+++ b/packages/adapter-sveltekit/vitest.config.ts
@@ -4,11 +4,6 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
alias: {
- '@holo-js/auth/client': resolve(__dirname, '../auth/src/client.ts'),
- '@holo-js/auth': resolve(__dirname, '../auth/src/index.ts'),
- '@holo-js/auth-social': resolve(__dirname, '../auth-social/src/index.ts'),
- '@holo-js/auth-workos': resolve(__dirname, '../auth-workos/src/index.ts'),
- '@holo-js/auth-clerk': resolve(__dirname, '../auth-clerk/src/index.ts'),
'@holo-js/config': resolve(__dirname, '../config/src/index.ts'),
'@holo-js/core': resolve(__dirname, '../core/src/index.ts'),
'@holo-js/db': resolve(__dirname, '../db/src/index.ts'),
diff --git a/packages/auth/package.json b/packages/auth/package.json
index 80c744e..84ff7fd 100644
--- a/packages/auth/package.json
+++ b/packages/auth/package.json
@@ -14,6 +14,36 @@
"types": "./dist/client.d.ts",
"import": "./dist/client.mjs",
"default": "./dist/client.mjs"
+ },
+ "./next/client": {
+ "types": "./dist/next/client.d.ts",
+ "import": "./dist/next/client.mjs",
+ "default": "./dist/next/client.mjs"
+ },
+ "./next/server": {
+ "types": "./dist/next/server.d.ts",
+ "import": "./dist/next/server.mjs",
+ "default": "./dist/next/server.mjs"
+ },
+ "./sveltekit/client": {
+ "types": "./dist/sveltekit/client.d.ts",
+ "import": "./dist/sveltekit/client.mjs",
+ "default": "./dist/sveltekit/client.mjs"
+ },
+ "./sveltekit/server": {
+ "types": "./dist/sveltekit/server.d.ts",
+ "import": "./dist/sveltekit/server.mjs",
+ "default": "./dist/sveltekit/server.mjs"
+ },
+ "./nuxt": {
+ "types": "./dist/nuxt.d.ts",
+ "import": "./dist/nuxt.mjs",
+ "default": "./dist/nuxt.mjs"
+ },
+ "./nuxt/server": {
+ "types": "./dist/nuxt/server.d.ts",
+ "import": "./dist/nuxt/server.mjs",
+ "default": "./dist/nuxt/server.mjs"
}
},
"main": "./dist/index.mjs",
@@ -31,14 +61,30 @@
"@holo-js/config": "^0.1.4"
},
"peerDependencies": {
- "@holo-js/security": "^0.1.4"
+ "@holo-js/security": "^0.1.4",
+ "nuxt": "catalog:",
+ "react": "^18.3.1 || ^19.0.0",
+ "svelte": "catalog:"
},
"peerDependenciesMeta": {
"@holo-js/security": {
"optional": true
+ },
+ "nuxt": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "svelte": {
+ "optional": true
}
},
"devDependencies": {
+ "@types/react": "^19.0.0",
+ "nuxt": "catalog:",
+ "react": "^19.0.0",
+ "svelte": "catalog:",
"@types/node": "^22.10.2",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
diff --git a/packages/auth/src/next/client.ts b/packages/auth/src/next/client.ts
new file mode 100644
index 0000000..b2b4b14
--- /dev/null
+++ b/packages/auth/src/next/client.ts
@@ -0,0 +1,79 @@
+'use client'
+
+import { createContext, createElement, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
+import { refreshUser as refreshCurrentUser } from '../client'
+import type { AuthClientRequestOptions, HoloAuthUser } from '../contracts'
+
+export type { HoloAuthUser } from '../contracts'
+
+type UseAuthRequestOptions = Pick
+
+export type UseAuthOptions = UseAuthRequestOptions & {
+ readonly initialUser?: HoloAuthUser | null
+}
+
+export type UseAuthResult = {
+ readonly authenticated: boolean
+ readonly user: HoloAuthUser | null
+ readonly refreshUser: () => Promise
+}
+
+export type AuthProviderProps = UseAuthOptions & {
+ readonly children: ReactNode
+}
+
+const AuthContext = createContext(null)
+
+function hasExplicitUseAuthOptions(options: UseAuthOptions | undefined): options is UseAuthOptions {
+ return typeof options !== 'undefined'
+ && Object.values(options).some(value => typeof value !== 'undefined')
+}
+
+function useAuthState(
+ options: UseAuthOptions = {},
+ stateOptions: { readonly refreshOnMount?: boolean } = {},
+): UseAuthResult {
+ const { initialUser, ...requestOptions } = options
+ const [currentUser, setCurrentUser] = useState(initialUser ?? null)
+ const requestOptionsRef = useRef(requestOptions)
+
+ requestOptionsRef.current = requestOptions
+
+ const refreshUser = useCallback(async () => {
+ const nextUser = await refreshCurrentUser(requestOptionsRef.current)
+ setCurrentUser(nextUser)
+ return nextUser
+ }, [])
+
+ useEffect(() => {
+ if (stateOptions.refreshOnMount !== false && typeof initialUser === 'undefined') {
+ void refreshUser()
+ }
+ }, [initialUser, refreshUser, stateOptions.refreshOnMount])
+
+ return {
+ authenticated: currentUser !== null,
+ user: currentUser,
+ refreshUser,
+ }
+}
+
+export function AuthProvider({ children, ...options }: AuthProviderProps): ReactNode {
+ const auth = useAuthState(options)
+
+ return createElement(AuthContext.Provider, { value: auth }, children)
+}
+
+export function useAuth(options?: UseAuthOptions): UseAuthResult {
+ const context = useContext(AuthContext)
+ const hasOptions = hasExplicitUseAuthOptions(options)
+ const localAuth = useAuthState(options, {
+ refreshOnMount: hasOptions || !context,
+ })
+
+ if (!hasOptions && context) {
+ return context
+ }
+
+ return localAuth
+}
diff --git a/packages/auth/src/next/request-context.ts b/packages/auth/src/next/request-context.ts
new file mode 100644
index 0000000..a47b86a
--- /dev/null
+++ b/packages/auth/src/next/request-context.ts
@@ -0,0 +1,30 @@
+import { AsyncLocalStorage } from 'node:async_hooks'
+
+export type NextAuthRequestLike = {
+ readonly cookies: {
+ get(name: string): { readonly value: string } | undefined
+ }
+ readonly headers: Headers
+}
+
+type NextAuthRequestGlobals = typeof globalThis & {
+ __holoNextAuthRequestStore?: AsyncLocalStorage
+}
+
+function getNextAuthRequestStore(): AsyncLocalStorage {
+ const globals = globalThis as NextAuthRequestGlobals
+ globals.__holoNextAuthRequestStore ??= new AsyncLocalStorage()
+
+ return globals.__holoNextAuthRequestStore
+}
+
+export function getCurrentNextAuthRequest(): NextAuthRequestLike | undefined {
+ return getNextAuthRequestStore().getStore()
+}
+
+export function runWithNextAuthRequest(
+ request: NextAuthRequestLike,
+ callback: () => TValue,
+): TValue {
+ return getNextAuthRequestStore().run(request, callback)
+}
diff --git a/packages/auth/src/next/server.ts b/packages/auth/src/next/server.ts
new file mode 100644
index 0000000..c4e3228
--- /dev/null
+++ b/packages/auth/src/next/server.ts
@@ -0,0 +1,159 @@
+import holoAuth, { user as currentUser } from '../index'
+import type { HoloAuthUser } from '../contracts'
+import { runWithNextAuthRequest, type NextAuthRequestLike } from './request-context'
+
+export type AuthState = {
+ readonly authenticated: boolean
+ readonly user: HoloAuthUser | null
+}
+
+export type AuthOptions = {
+ readonly guard?: string
+}
+
+export type RouteMatcher = string | RegExp | ((pathname: string) => boolean)
+
+export type GuestOnlyOptions = AuthOptions & {
+ readonly redirectTo: string
+ readonly routes?: readonly RouteMatcher[]
+ readonly status?: 301 | 302 | 303 | 307 | 308
+}
+
+export type AuthOnlyOptions = AuthOptions & {
+ readonly redirectTo: string
+ readonly routes?: readonly RouteMatcher[]
+ readonly status?: 301 | 302 | 303 | 307 | 308
+}
+
+type NextRouteProtectionRequest = NextAuthRequestLike & {
+ readonly nextUrl?: URL
+ readonly url: string
+}
+
+type NextRouteProtectionResult = Response | undefined | void
+
+type NextRouteProtectionProxy = (
+ request: NextRouteProtectionRequest,
+) => NextRouteProtectionResult | Promise
+
+function toClientAuthUser(user: HoloAuthUser | null): HoloAuthUser | null {
+ return user ? { ...user } : null
+}
+
+function normalizePathname(pathname: string): string {
+ if (pathname === '/') {
+ return pathname
+ }
+
+ return pathname.replace(/\/+$/g, '')
+}
+
+function matchesRoute(route: RouteMatcher, pathname: string): boolean {
+ const normalizedPathname = normalizePathname(pathname)
+ if (typeof route === 'function') {
+ return route(normalizedPathname)
+ }
+
+ if (route instanceof RegExp) {
+ route.lastIndex = 0
+ return route.test(normalizedPathname)
+ }
+
+ const normalizedRoute = normalizePathname(route)
+ if (normalizedRoute.endsWith('/*')) {
+ const prefix = normalizePathname(normalizedRoute.slice(0, -2))
+ return normalizedPathname === prefix || normalizedPathname.startsWith(`${prefix}/`)
+ }
+
+ return normalizedPathname === normalizedRoute
+}
+
+function matchesRoutes(routes: readonly RouteMatcher[] | undefined, pathname: string): boolean {
+ return (routes ?? ['/*']).some(route => matchesRoute(route, pathname))
+}
+
+function isSameUrl(left: URL, right: URL): boolean {
+ return left.origin === right.origin
+ && left.pathname === right.pathname
+ && left.search === right.search
+ && left.hash === right.hash
+}
+
+export async function auth(options: AuthOptions = {}): Promise {
+ const user = options.guard
+ ? await holoAuth.guard(options.guard).user()
+ : await currentUser()
+ const clientUser = toClientAuthUser(user)
+
+ return {
+ authenticated: clientUser !== null,
+ user: clientUser,
+ }
+}
+
+export function guestOnly(options: GuestOnlyOptions): NextRouteProtectionProxy {
+ return async function proxy(request) {
+ const requestUrl = request.nextUrl ?? new URL(request.url)
+ if (!matchesRoutes(options.routes, requestUrl.pathname)) {
+ return undefined
+ }
+
+ return runWithNextAuthRequest(request, async () => {
+ const currentAuth = await auth({ guard: options.guard })
+ if (!currentAuth.authenticated) {
+ return undefined
+ }
+
+ const redirectUrl = new URL(options.redirectTo, request.url)
+ if (isSameUrl(requestUrl, redirectUrl)) {
+ return undefined
+ }
+
+ return Response.redirect(redirectUrl, options.status ?? 303)
+ })
+ }
+}
+
+export function authOnly(options: AuthOnlyOptions): NextRouteProtectionProxy {
+ return async function proxy(request) {
+ const requestUrl = request.nextUrl ?? new URL(request.url)
+ if (!matchesRoutes(options.routes, requestUrl.pathname)) {
+ return undefined
+ }
+
+ return runWithNextAuthRequest(request, async () => {
+ const currentAuth = await auth({ guard: options.guard })
+ if (currentAuth.authenticated) {
+ return undefined
+ }
+
+ const redirectUrl = new URL(options.redirectTo, request.url)
+ if (isSameUrl(requestUrl, redirectUrl)) {
+ return undefined
+ }
+
+ return Response.redirect(redirectUrl, options.status ?? 303)
+ })
+ }
+}
+
+export function protectRoutes(...proxies: readonly NextRouteProtectionProxy[]): NextRouteProtectionProxy {
+ return async function proxy(request) {
+ for (const routeProxy of proxies) {
+ const response = await routeProxy(request)
+ if (response) {
+ return response
+ }
+ }
+
+ return undefined
+ }
+}
+
+export const routeProtectionInternals = {
+ isSameUrl,
+ matchesRoute,
+ matchesRoutes,
+}
+
+export { getCurrentNextAuthRequest, runWithNextAuthRequest, type NextAuthRequestLike } from './request-context'
diff --git a/packages/auth/src/nuxt-shim.d.ts b/packages/auth/src/nuxt-shim.d.ts
new file mode 100644
index 0000000..f0dd0f4
--- /dev/null
+++ b/packages/auth/src/nuxt-shim.d.ts
@@ -0,0 +1,34 @@
+interface HoloRef {
+ value: TValue
+}
+
+interface HoloComputedRef {
+ readonly value: TValue
+}
+
+interface HoloUseFetchResult {
+ readonly data: HoloRef
+ readonly refresh: () => Promise
+}
+
+interface HoloRouteLocation {
+ readonly path: string
+}
+
+type HoloNavigateToResult = void | false | Promise
+
+declare module '#imports' {
+ export function computed(getter: () => TValue): HoloComputedRef
+ export function defineNuxtRouteMiddleware(
+ middleware: (to: HoloRouteLocation, from: HoloRouteLocation) => TValue | Promise,
+ ): (to: HoloRouteLocation, from: HoloRouteLocation) => TValue | Promise
+ export function navigateTo(
+ to: string,
+ options?: { readonly redirectCode?: number },
+ ): HoloNavigateToResult
+ export function useFetch(
+ request: string,
+ options?: { readonly key?: string },
+ ): Promise>
+ export function useState(key: string, init: () => TValue): HoloRef
+}
diff --git a/packages/adapter-nuxt/src/runtime/composables/auth.ts b/packages/auth/src/nuxt.ts
similarity index 93%
rename from packages/adapter-nuxt/src/runtime/composables/auth.ts
rename to packages/auth/src/nuxt.ts
index 23763dd..5ecc037 100644
--- a/packages/adapter-nuxt/src/runtime/composables/auth.ts
+++ b/packages/auth/src/nuxt.ts
@@ -1,7 +1,7 @@
-import type { CurrentAuthResponse, HoloAuthUser } from '@holo-js/auth'
+import type { CurrentAuthResponse, HoloAuthUser } from './contracts'
import { computed, useFetch, useState } from '#imports'
-export type { HoloAuthUser } from '@holo-js/auth'
+export type { HoloAuthUser } from './contracts'
export type UseAuthOptions = {
readonly endpoint?: string
diff --git a/packages/auth/src/nuxt/server.ts b/packages/auth/src/nuxt/server.ts
new file mode 100644
index 0000000..0fb0abd
--- /dev/null
+++ b/packages/auth/src/nuxt/server.ts
@@ -0,0 +1,126 @@
+import { useAuth } from '../nuxt'
+import { defineNuxtRouteMiddleware, navigateTo } from '#imports'
+
+export type RouteMatcher = string | RegExp | ((pathname: string) => boolean)
+
+export type GuestOnlyOptions = {
+ readonly redirectTo: string
+ readonly routes?: readonly RouteMatcher[]
+ readonly status?: 301 | 302 | 303 | 307 | 308
+}
+
+export type AuthOnlyOptions = {
+ readonly redirectTo: string
+ readonly routes?: readonly RouteMatcher[]
+ readonly status?: 301 | 302 | 303 | 307 | 308
+}
+
+export type RouteProtectionLocation = {
+ readonly path: string
+}
+
+export type RouteProtectionMiddlewareResult = void | false | Promise
+
+export type RouteProtectionMiddleware = (
+ to: RouteProtectionLocation,
+ from: RouteProtectionLocation,
+) => RouteProtectionMiddlewareResult
+
+export type GuestOnlyRouteLocation = RouteProtectionLocation
+export type GuestOnlyRouteMiddlewareResult = RouteProtectionMiddlewareResult
+export type GuestOnlyRouteMiddleware = RouteProtectionMiddleware
+export type AuthOnlyRouteLocation = RouteProtectionLocation
+export type AuthOnlyRouteMiddlewareResult = RouteProtectionMiddlewareResult
+export type AuthOnlyRouteMiddleware = RouteProtectionMiddleware
+
+function normalizePathname(pathname: string): string {
+ if (pathname === '/') {
+ return pathname
+ }
+
+ return pathname.replace(/\/+$/g, '')
+}
+
+function matchesRoute(route: RouteMatcher, pathname: string): boolean {
+ const normalizedPathname = normalizePathname(pathname)
+ if (typeof route === 'function') {
+ return route(normalizedPathname)
+ }
+
+ if (route instanceof RegExp) {
+ route.lastIndex = 0
+ return route.test(normalizedPathname)
+ }
+
+ const normalizedRoute = normalizePathname(route)
+ if (normalizedRoute.endsWith('/*')) {
+ const prefix = normalizePathname(normalizedRoute.slice(0, -2))
+ return normalizedPathname === prefix || normalizedPathname.startsWith(`${prefix}/`)
+ }
+
+ return normalizedPathname === normalizedRoute
+}
+
+function matchesRoutes(routes: readonly RouteMatcher[] | undefined, pathname: string): boolean {
+ return (routes ?? ['/*']).some(route => matchesRoute(route, pathname))
+}
+
+function isSamePath(path: string, redirectTo: string): boolean {
+ const resolvePathname = (value: string): string => {
+ try {
+ return new URL(value, 'https://holo.local').pathname
+ } catch {
+ return value.split(/[?#]/, 1)[0] ?? value
+ }
+ }
+
+ return normalizePathname(resolvePathname(path)) === normalizePathname(resolvePathname(redirectTo))
+}
+
+export function guestOnly(options: GuestOnlyOptions): GuestOnlyRouteMiddleware {
+ return defineNuxtRouteMiddleware(async (to) => {
+ if (!matchesRoutes(options.routes, to.path)) {
+ return undefined
+ }
+
+ const currentAuth = await useAuth()
+ if (!currentAuth.authenticated.value) {
+ return undefined
+ }
+
+ if (isSamePath(to.path, options.redirectTo)) {
+ return undefined
+ }
+
+ return navigateTo(options.redirectTo, {
+ redirectCode: options.status ?? 303,
+ })
+ })
+}
+
+export function authOnly(options: AuthOnlyOptions): AuthOnlyRouteMiddleware {
+ return defineNuxtRouteMiddleware(async (to) => {
+ if (!matchesRoutes(options.routes, to.path)) {
+ return undefined
+ }
+
+ const currentAuth = await useAuth()
+ if (currentAuth.authenticated.value) {
+ return undefined
+ }
+
+ if (isSamePath(to.path, options.redirectTo)) {
+ return undefined
+ }
+
+ return navigateTo(options.redirectTo, {
+ redirectCode: options.status ?? 303,
+ })
+ })
+}
+
+export const routeProtectionInternals = {
+ isSamePath,
+ matchesRoute,
+ matchesRoutes,
+}
diff --git a/packages/auth/src/sveltekit/client.ts b/packages/auth/src/sveltekit/client.ts
new file mode 100644
index 0000000..b0d5cb3
--- /dev/null
+++ b/packages/auth/src/sveltekit/client.ts
@@ -0,0 +1,116 @@
+import { getContext, setContext } from 'svelte'
+import { createSubscriber } from 'svelte/reactivity'
+import { refreshUser as refreshCurrentUser } from '../client'
+import type { AuthClientRequestOptions, HoloAuthUser } from '../contracts'
+
+export type { HoloAuthUser } from '../contracts'
+
+export type UseAuthOptions = AuthClientRequestOptions & {
+ readonly initialUser?: HoloAuthUser | null
+}
+
+export type UseAuthResult = {
+ readonly authenticated: boolean
+ readonly user: HoloAuthUser | null
+ readonly refreshUser: () => Promise
+}
+
+const authContextKey = Symbol('holo-js.auth.client')
+
+export function setAuthContext(auth: UseAuthResult): UseAuthResult {
+ setContext(authContextKey, auth)
+ return auth
+}
+
+export function getAuthContext(): UseAuthResult | undefined {
+ return getContext(authContextKey)
+}
+
+function tryGetAuthContext(): UseAuthResult | undefined {
+ try {
+ return getAuthContext()
+ } catch {
+ return undefined
+ }
+}
+
+function trySetAuthContext(auth: UseAuthResult): void {
+ try {
+ setAuthContext(auth)
+ } catch {
+ // Outside component initialization there is no Svelte context to attach.
+ }
+}
+
+class AuthClientState implements UseAuthResult {
+ #notify: () => void = () => {}
+ #pendingRefresh: Promise | undefined
+ #user: HoloAuthUser | null
+
+ readonly #subscribe = createSubscriber((update) => {
+ this.#notify = update
+
+ return () => {
+ this.#notify = () => {}
+ }
+ })
+
+ constructor(
+ initialUser: HoloAuthUser | null,
+ private requestOptions: AuthClientRequestOptions,
+ ) {
+ this.#user = initialUser
+ }
+
+ get authenticated(): boolean {
+ this.#subscribe()
+ return this.#user !== null
+ }
+
+ get user(): HoloAuthUser | null {
+ this.#subscribe()
+ return this.#user
+ }
+
+ async refreshUser(): Promise {
+ if (this.#pendingRefresh) {
+ return this.#pendingRefresh
+ }
+
+ const refresh = refreshCurrentUser(this.requestOptions)
+ .then((user) => {
+ this.#user = user
+ this.#notify()
+
+ return user
+ })
+ .finally(() => {
+ this.#pendingRefresh = undefined
+ })
+
+ this.#pendingRefresh = refresh
+ return refresh
+ }
+
+ setRequestOptions(requestOptions: AuthClientRequestOptions): void {
+ this.requestOptions = requestOptions
+ }
+}
+
+export function useAuth(options?: UseAuthOptions): UseAuthResult {
+ const context = tryGetAuthContext()
+ const resolvedOptions = options ?? {}
+ const { initialUser = null, ...requestOptions } = resolvedOptions
+ if (context && typeof options?.initialUser === 'undefined') {
+ if (context instanceof AuthClientState) {
+ context.setRequestOptions(requestOptions)
+ }
+
+ return context
+ }
+
+ const auth = new AuthClientState(initialUser, requestOptions)
+
+ trySetAuthContext(auth)
+ return auth
+}
diff --git a/packages/adapter-sveltekit/src/server.ts b/packages/auth/src/sveltekit/server.ts
similarity index 72%
rename from packages/adapter-sveltekit/src/server.ts
rename to packages/auth/src/sveltekit/server.ts
index f91c82c..78aad3c 100644
--- a/packages/adapter-sveltekit/src/server.ts
+++ b/packages/auth/src/sveltekit/server.ts
@@ -1,5 +1,5 @@
-import holoAuth, { user as currentUser } from '@holo-js/auth'
-import type { AuthUserLike, HoloAuthUser } from '@holo-js/auth'
+import holoAuth, { user as currentUser } from '../index'
+import type { AuthUserLike, HoloAuthUser } from '../contracts'
export type AuthState = {
readonly authenticated: boolean
@@ -18,13 +18,19 @@ export type GuestOnlyOptions = AuthOptions & {
readonly status?: 301 | 302 | 303 | 307 | 308
}
+export type AuthOnlyOptions = AuthOptions & {
+ readonly redirectTo: string
+ readonly routes?: readonly RouteMatcher[]
+ readonly status?: 301 | 302 | 303 | 307 | 308
+}
+
export type SvelteKitHandleEvent = {
readonly url: URL
}
export type SvelteKitHandleInput = {
readonly event: TEvent
- readonly resolve: (event: TEvent, options?: unknown) => Response | Promise
+ readonly resolve: (event: TEvent, options?: never) => Response | Promise
}
export type SvelteKitHandle = (
@@ -68,6 +74,12 @@ function matchesRoutes(routes: readonly RouteMatcher[] | undefined, pathname: st
return (routes ?? ['/*']).some(route => matchesRoute(route, pathname))
}
+function isSameUrl(left: URL, right: URL): boolean {
+ return left.pathname === right.pathname
+ && left.search === right.search
+ && left.hash === right.hash
+}
+
export async function auth(options: AuthOptions = {}): Promise {
let user: HoloAuthUser | null
try {
@@ -102,11 +114,27 @@ export function guestOnly(options: GuestOnlyOptions): SvelteKitHandle {
}
const redirectUrl = new URL(options.redirectTo, event.url)
- if (
- redirectUrl.pathname === event.url.pathname
- && redirectUrl.search === event.url.search
- && redirectUrl.hash === event.url.hash
- ) {
+ if (isSameUrl(event.url, redirectUrl)) {
+ return resolve(event)
+ }
+
+ return Response.redirect(redirectUrl, options.status ?? 303)
+ }
+}
+
+export function authOnly(options: AuthOnlyOptions): SvelteKitHandle {
+ return async ({ event, resolve }) => {
+ if (!matchesRoutes(options.routes, event.url.pathname)) {
+ return resolve(event)
+ }
+
+ const currentAuth = await auth({ guard: options.guard })
+ if (currentAuth.authenticated) {
+ return resolve(event)
+ }
+
+ const redirectUrl = new URL(options.redirectTo, event.url)
+ if (isSameUrl(event.url, redirectUrl)) {
return resolve(event)
}
@@ -115,6 +143,7 @@ export function guestOnly(options: GuestOnlyOptions): SvelteKitHandle {
}
export const routeProtectionInternals = {
+ isSameUrl,
matchesRoute,
matchesRoutes,
}
diff --git a/packages/auth/tests/contracts.type.test.ts b/packages/auth/tests/contracts.type.test.ts
index 1c0cb0f..f68f62e 100644
--- a/packages/auth/tests/contracts.type.test.ts
+++ b/packages/auth/tests/contracts.type.test.ts
@@ -1,6 +1,9 @@
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 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'
+import type { useAuth as useSvelteKitAuth } from '../src/sveltekit/client'
declare module '../src' {
interface HoloAuthTypeRegistry {
@@ -28,6 +31,9 @@ describe('@holo-js/auth typing', () => {
type CurrentServerUser = Awaited>
type CurrentClientUser = Awaited>
type CurrentClientAuth = Awaited>
+ type CurrentNextAuth = ReturnType
+ type CurrentNuxtAuth = Awaited>
+ type CurrentSvelteKitAuth = ReturnType
type RefreshedClientUser = Awaited>
type GuardUser = Awaited>
type GuardRefreshedUser = Awaited>
@@ -48,6 +54,9 @@ describe('@holo-js/auth typing', () => {
readonly check: () => boolean
readonly refreshUser: () => Promise
}>()
+ expectTypeOf().toEqualTypeOf()
+ expectTypeOf().toEqualTypeOf()
+ expectTypeOf().toEqualTypeOf()
expectTypeOf().toEqualTypeOf()
expectTypeOf().toEqualTypeOf()
expectTypeOf().toEqualTypeOf()
diff --git a/packages/auth/tests/docs-smoke.test.ts b/packages/auth/tests/docs-smoke.test.ts
index 39ec836..2df9889 100644
--- a/packages/auth/tests/docs-smoke.test.ts
+++ b/packages/auth/tests/docs-smoke.test.ts
@@ -77,9 +77,9 @@ describe('auth documentation smoke checks', () => {
const reset = await readDoc('password-reset.md')
expect(client).toContain('@holo-js/auth/client')
- expect(client).toContain('@holo-js/adapter-next/client')
- expect(client).toContain('@holo-js/adapter-nuxt/client')
- expect(client).toContain('@holo-js/adapter-sveltekit/client')
+ expect(client).toContain('@holo-js/auth/next/client')
+ expect(client).toContain('@holo-js/auth/nuxt')
+ expect(client).toContain('@holo-js/auth/sveltekit/client')
expect(client).toContain('HoloAuthUser')
expect(client).toContain('useAuth')
expect(client).toContain('refreshUser')
diff --git a/packages/auth/tests/framework.test.ts b/packages/auth/tests/framework.test.ts
new file mode 100644
index 0000000..8adf9fd
--- /dev/null
+++ b/packages/auth/tests/framework.test.ts
@@ -0,0 +1,121 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+type MockReactContext = {
+ currentRenderValue: TValue
+ readonly Provider: (props: { readonly value: TValue, readonly children?: unknown }) => unknown
+}
+
+function createReactMock(): Readonly> {
+ return {
+ createContext(defaultValue: TValue): MockReactContext {
+ const context: MockReactContext = {
+ currentRenderValue: defaultValue,
+ Provider({ value, children }) {
+ context.currentRenderValue = value
+ return children
+ },
+ }
+
+ return context
+ },
+ createElement(type: unknown, props: Record | null, ...children: readonly unknown[]): unknown {
+ if (typeof type === 'function') {
+ return (type as (props: Record) => unknown)({
+ ...(props ?? {}),
+ children: children.length === 1 ? children[0] : children,
+ })
+ }
+
+ return { type, props, children }
+ },
+ useCallback unknown>(callback: TCallback) {
+ return callback
+ },
+ useContext(context: MockReactContext): TValue {
+ return context.currentRenderValue
+ },
+ useEffect(effect: () => void | (() => void)) {
+ return effect()
+ },
+ useRef(initialValue?: TValue) {
+ return { current: initialValue }
+ },
+ useState(initialState: TValue | (() => TValue)) {
+ const value = typeof initialState === 'function'
+ ? (initialState as () => TValue)()
+ : initialState
+
+ return [value, vi.fn()] as const
+ },
+ }
+}
+
+describe('@holo-js/auth framework helpers', () => {
+ afterEach(() => {
+ vi.resetModules()
+ vi.clearAllMocks()
+ vi.doUnmock('#imports')
+ vi.doUnmock('react')
+ vi.doUnmock('../src/client')
+ })
+
+ it('reuses the Next auth provider when useAuth receives an empty options object', async () => {
+ const refreshUser = vi.fn(async () => null)
+ vi.doMock('../src/client', () => ({
+ refreshUser,
+ }))
+ vi.doMock('react', () => createReactMock())
+
+ const { AuthProvider, useAuth } = await import('../src/next/client')
+
+ AuthProvider({
+ initialUser: {
+ id: 1,
+ email: 'ava@example.com',
+ name: 'Ava',
+ role: 'admin',
+ },
+ children: null,
+ })
+
+ const auth = useAuth({})
+
+ expect(auth.user?.email).toBe('ava@example.com')
+ expect(refreshUser).not.toHaveBeenCalled()
+ })
+
+ it('does not treat cross-origin Next redirects as self redirects', async () => {
+ const { routeProtectionInternals } = await import('../src/next/server')
+
+ expect(routeProtectionInternals.isSameUrl(
+ new URL('https://app.test/login'),
+ new URL('https://other.test/login'),
+ )).toBe(false)
+ })
+
+ it('compares Nuxt self redirects by pathname without query strings', async () => {
+ vi.doMock('#imports', () => ({
+ computed(getter: () => TValue) {
+ return {
+ get value() {
+ return getter()
+ },
+ }
+ },
+ defineNuxtRouteMiddleware(middleware: TValue) {
+ return middleware
+ },
+ navigateTo: vi.fn(),
+ useFetch: vi.fn(),
+ useState(_key: string, init: () => TValue) {
+ return { value: init() }
+ },
+ }))
+
+ const { routeProtectionInternals } = await import('../src/nuxt/server')
+
+ expect(routeProtectionInternals.isSamePath('/login', '/login?returnUrl=/admin')).toBe(true)
+ expect(routeProtectionInternals.isSamePath('/login', 'https://app.test/login#top')).toBe(true)
+ expect(routeProtectionInternals.isSamePath('/login', '/register?returnUrl=/admin')).toBe(false)
+ })
+})
diff --git a/packages/auth/tests/package.test.ts b/packages/auth/tests/package.test.ts
index 8ac0404..b7595f7 100644
--- a/packages/auth/tests/package.test.ts
+++ b/packages/auth/tests/package.test.ts
@@ -647,6 +647,12 @@ describe('@holo-js/auth package runtime', () => {
expect(packageJson.peerDependencies?.['@holo-js/security']).toBe('^0.1.4')
expect(packageJson.peerDependenciesMeta?.['@holo-js/security']?.optional).toBe(true)
+ expect(packageJson.peerDependencies?.react).toBeDefined()
+ expect(packageJson.peerDependencies?.svelte).toBeDefined()
+ expect(packageJson.peerDependencies?.nuxt).toBeDefined()
+ expect(packageJson.peerDependenciesMeta?.react?.optional).toBe(true)
+ expect(packageJson.peerDependenciesMeta?.svelte?.optional).toBe(true)
+ expect(packageJson.peerDependenciesMeta?.nuxt?.optional).toBe(true)
})
it('keeps the client auth entry read-only', () => {
diff --git a/packages/auth/tsup.config.ts b/packages/auth/tsup.config.ts
index 11e5310..601e157 100644
--- a/packages/auth/tsup.config.ts
+++ b/packages/auth/tsup.config.ts
@@ -6,10 +6,17 @@ export default defineConfig({
entry: {
index: 'src/index.ts',
client: 'src/client.ts',
+ 'next/client': 'src/next/client.ts',
+ 'next/server': 'src/next/server.ts',
+ 'sveltekit/client': 'src/sveltekit/client.ts',
+ 'sveltekit/server': 'src/sveltekit/server.ts',
+ nuxt: 'src/nuxt.ts',
+ 'nuxt/server': 'src/nuxt/server.ts',
},
format: ['esm'],
dts: true,
clean: true,
+ external: ['#imports', 'react', 'svelte', 'svelte/reactivity'],
outDir,
outExtension: () => ({ js: '.mjs' }),
esbuildOptions(options) {
diff --git a/tests/example-app-auth-flow.mjs b/tests/example-app-auth-flow.mjs
index d2eec24..3dd76df 100644
--- a/tests/example-app-auth-flow.mjs
+++ b/tests/example-app-auth-flow.mjs
@@ -363,6 +363,11 @@ export async function assertExampleAppAuthFlow({
allowFailure: true,
}), '/admin')
}
+
+ const adminPostsPage = await fetchAuthText('/admin/posts', {
+ jar: authenticatedJar,
+ })
+ assert.match(adminPostsPage.text, /Designing the Example App Roadmap/i)
}
const authenticatedSessionCookie = authenticatedJar.header()