diff --git a/apps/blog-next/app/api/auth/clerk/logout/route.ts b/apps/blog-next/app/api/auth/clerk/logout/route.ts index 68a0d19..d6beb30 100644 --- a/apps/blog-next/app/api/auth/clerk/logout/route.ts +++ b/apps/blog-next/app/api/auth/clerk/logout/route.ts @@ -1,7 +1,29 @@ +import { provider } from '@holo-js/auth' import { logoutWithClerk } from '@holo-js/auth-clerk' +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + export async function POST(request: Request) { - const result = await logoutWithClerk(request) + let currentProvider: string | null + try { + currentProvider = await provider() + } catch (error) { + return Response.json({ ok: false, error: getErrorMessage(error) }, { status: 500 }) + } + + if (currentProvider !== 'clerk') { + return Response.redirect(new URL('/', request.url), 303) + } + + let result: Awaited> + try { + result = await logoutWithClerk(request) + } catch (error) { + return Response.json({ ok: false, error: getErrorMessage(error) }, { status: 500 }) + } + if (!result.ok) { return Response.json(result, { status: 422 }) } diff --git a/apps/blog-next/app/api/auth/user/route.ts b/apps/blog-next/app/api/auth/user/route.ts index 1777ac2..73f2b57 100644 --- a/apps/blog-next/app/api/auth/user/route.ts +++ b/apps/blog-next/app/api/auth/user/route.ts @@ -1,9 +1,13 @@ -import { check, user } from '@holo-js/auth' +import auth, { check, provider, user } from '@holo-js/auth' + +export async function GET(request: Request) { + const guard = new URL(request.url).searchParams.get('guard') ?? undefined + const guardAuth = guard ? auth.guard(guard) : undefined -export async function GET() { return Response.json({ - authenticated: await check(), - guard: 'web', - user: await user(), + authenticated: guardAuth ? await guardAuth.check() : await check(), + guard: guard ?? 'web', + provider: guardAuth ? await guardAuth.provider() : await provider(), + user: guardAuth ? await guardAuth.user() : await user(), }) } diff --git a/apps/blog-next/app/api/auth/workos/logout/route.ts b/apps/blog-next/app/api/auth/workos/logout/route.ts index d556de0..118cb66 100644 --- a/apps/blog-next/app/api/auth/workos/logout/route.ts +++ b/apps/blog-next/app/api/auth/workos/logout/route.ts @@ -1,7 +1,29 @@ +import { provider } from '@holo-js/auth' import { logoutWithWorkos } from '@holo-js/auth-workos' +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + export async function POST(request: Request) { - const result = await logoutWithWorkos(request) + let currentProvider: string | null + try { + currentProvider = await provider() + } catch (error) { + return Response.json({ ok: false, error: getErrorMessage(error) }, { status: 500 }) + } + + if (currentProvider !== 'workos') { + return Response.redirect(new URL('/', request.url), 303) + } + + let result: Awaited> + try { + result = await logoutWithWorkos(request) + } catch (error) { + return Response.json({ ok: false, error: getErrorMessage(error) }, { status: 500 }) + } + if (!result.ok) { return Response.json(result, { status: 422 }) } diff --git a/apps/blog-next/app/api/super-admin/login/route.ts b/apps/blog-next/app/api/super-admin/login/route.ts new file mode 100644 index 0000000..3e45e07 --- /dev/null +++ b/apps/blog-next/app/api/super-admin/login/route.ts @@ -0,0 +1,32 @@ +import auth from '@holo-js/auth' +import { validate } from '@holo-js/forms' + +import { loginForm } from '@/lib/schemas/auth' + +export async function POST(request: Request) { + const submission = await validate(request, loginForm, { + throttle: 'login', + }) + + if (!submission.valid) { + return Response.json(submission.fail(), { + status: submission.fail().status, + }) + } + + const { data: session, error } = await auth.guard('admin').login(submission.data) + if (error) { + const failure = submission.fail({ + status: error.status, + errors: error.fields, + }) + + return Response.json(failure, { status: failure.status }) + } + + return Response.json(submission.success({ + message: 'Signed in as super admin.', + redirectTo: '/super-admin', + user: session.user, + })) +} diff --git a/apps/blog-next/app/api/super-admin/logout/route.ts b/apps/blog-next/app/api/super-admin/logout/route.ts new file mode 100644 index 0000000..2669ef8 --- /dev/null +++ b/apps/blog-next/app/api/super-admin/logout/route.ts @@ -0,0 +1,13 @@ +import auth from '@holo-js/auth' + +export async function POST() { + const admin = auth.guard('admin') + await admin.logout() + + return Response.json({ + ok: true, + authenticated: false, + message: 'Signed out of super admin.', + user: await admin.user(), + }) +} diff --git a/apps/blog-next/app/auth-nav.tsx b/apps/blog-next/app/auth-nav.tsx index 82a76be..3128bd2 100644 --- a/apps/blog-next/app/auth-nav.tsx +++ b/apps/blog-next/app/auth-nav.tsx @@ -61,12 +61,16 @@ export function AuthNav() { <> {displayName} -
- -
-
- -
+ {auth.provider === 'workos' && ( +
+ +
+ )} + {auth.provider === 'clerk' && ( +
+ +
+ )} ) } diff --git a/apps/blog-next/app/layout.tsx b/apps/blog-next/app/layout.tsx index 577bf2a..92b27e4 100644 --- a/apps/blog-next/app/layout.tsx +++ b/apps/blog-next/app/layout.tsx @@ -25,7 +25,8 @@ export default async function RootLayout({ children }: { children: ReactNode }) blog-next Posts Admin - + Super Admin + diff --git a/apps/blog-next/app/super-admin/login/page.tsx b/apps/blog-next/app/super-admin/login/page.tsx new file mode 100644 index 0000000..b534ca3 --- /dev/null +++ b/apps/blog-next/app/super-admin/login/page.tsx @@ -0,0 +1,93 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useAuth } from '@holo-js/auth/next/client' +import { useForm } from '@holo-js/adapter-next/client' +import { loginForm } from '@/lib/schemas/auth' + +const panelStyle = { + display: 'grid', + gap: '1rem', + maxWidth: '32rem', + padding: '1.5rem', + borderRadius: '1rem', + background: '#111827', + border: '1px solid rgba(148, 163, 184, 0.16)', +} satisfies React.CSSProperties + +export default function SuperAdminLoginPage() { + const router = useRouter() + const auth = useAuth({ guard: 'admin' }) + const form = useForm(loginForm, { + validateOn: 'blur', + initialValues: { email: '', password: '', remember: false }, + async submitter({ formData }) { + const response = await fetch('/api/super-admin/login', { method: 'POST', body: formData }) + const submission = await response.json() + if (submission?.ok === true && typeof submission.data?.redirectTo === 'string') { + try { + await auth.refreshUser() + } catch (error) { + console.warn('Admin auth refresh failed after login.', error) + } + router.replace(submission.data.redirectTo) + } + return submission + }, + }) + + return ( +
+
+

Super Admin Sign In

+

Use an admin account to access the super admin area.

+
+ +
{ event.preventDefault(); form.submit() }} style={{ display: 'grid', gap: '0.9rem' }}> + + + + + + + +
+ + {form.lastSubmission?.ok === true ? ( +
+

Signed in as super admin.

+
+ ) : null} +
+ ) +} diff --git a/apps/blog-next/app/super-admin/logout-button.tsx b/apps/blog-next/app/super-admin/logout-button.tsx new file mode 100644 index 0000000..4deb6e0 --- /dev/null +++ b/apps/blog-next/app/super-admin/logout-button.tsx @@ -0,0 +1,39 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { useAuth } from '@holo-js/auth/next/client' + +export function SuperAdminLogoutButton() { + const router = useRouter() + const auth = useAuth({ guard: 'admin' }) + const [isLoggingOut, setIsLoggingOut] = useState(false) + + async function logout() { + if (isLoggingOut) { + return + } + + setIsLoggingOut(true) + try { + const response = await fetch('/api/super-admin/logout', { method: 'POST' }) + if (!response.ok) { + console.warn('Super admin logout failed.', { status: response.status }) + return + } + + await auth.refreshUser() + router.replace('/super-admin/login') + } catch (error) { + console.warn('Super admin logout failed.', error) + } finally { + setIsLoggingOut(false) + } + } + + return ( + + ) +} diff --git a/apps/blog-next/app/super-admin/page.tsx b/apps/blog-next/app/super-admin/page.tsx new file mode 100644 index 0000000..03e1c70 --- /dev/null +++ b/apps/blog-next/app/super-admin/page.tsx @@ -0,0 +1,24 @@ +import { redirect } from 'next/navigation' +import { auth } from '@holo-js/auth/next/server' +import { SuperAdminLogoutButton } from './logout-button' + +export default async function SuperAdminPage() { + const currentAuth = await auth({ guard: 'admin' }) + + if (!currentAuth.authenticated) { + redirect('/super-admin/login') + } + + const displayName = currentAuth.user?.name ?? currentAuth.user?.email ?? 'Super Admin' + + return ( +
+

Admin guard

+

Super Admin

+

Signed in as {displayName} through the admin guard.

+
+ +
+
+ ) +} diff --git a/apps/blog-next/config/auth.ts b/apps/blog-next/config/auth.ts index c1ec02f..d460b04 100644 --- a/apps/blog-next/config/auth.ts +++ b/apps/blog-next/config/auth.ts @@ -10,20 +10,20 @@ export default defineAuthConfig({ driver: 'session', provider: 'users', }, - // admin: { - // driver: 'session', - // provider: 'admins', - // }, + admin: { + driver: 'session', + provider: 'admins', + }, }, providers: { users: { model: 'User', identifiers: ['email'], }, - // admins: { - // model: 'Admin', - // identifiers: ['email'], - // }, + admins: { + model: 'Admin', + identifiers: ['email'], + }, }, passwords: { users: { diff --git a/apps/blog-next/proxy.ts b/apps/blog-next/proxy.ts index f202051..8d2454c 100644 --- a/apps/blog-next/proxy.ts +++ b/apps/blog-next/proxy.ts @@ -9,8 +9,18 @@ export const proxy = protectRoutes( routes: ['/admin/*'], redirectTo: '/login', }), + guestOnly({ + routes: ['/super-admin/login'], + guard: 'admin', + redirectTo: '/super-admin', + }), + authOnly({ + routes: ['/super-admin'], + guard: 'admin', + redirectTo: '/super-admin/login', + }), ) export const config = { - matcher: ['/login', '/register', '/forgot-password', '/reset-password', '/admin/:path*'], + matcher: ['/login', '/register', '/forgot-password', '/reset-password', '/admin/:path*', '/super-admin', '/super-admin/login'], } diff --git a/apps/blog-next/server/db/migrations/2026_04_25_044031_create_admins.ts b/apps/blog-next/server/db/migrations/2026_04_25_044031_create_admins.ts new file mode 100644 index 0000000..e2eab0a --- /dev/null +++ b/apps/blog-next/server/db/migrations/2026_04_25_044031_create_admins.ts @@ -0,0 +1,18 @@ +import { defineMigration, type MigrationContext } from '@holo-js/db' + +export default defineMigration({ + async up({ schema }: MigrationContext) { + await schema.createTable('admins', (table) => { + table.id() + table.string('name') + table.string('email').unique() + table.string('password').nullable() + table.string('avatar').nullable() + table.timestamp('email_verified_at').nullable() + table.timestamps() + }) + }, + async down({ schema }: MigrationContext) { + await schema.dropTable('admins') + }, +}) diff --git a/apps/blog-next/server/db/seeders/BlogSeeder.ts b/apps/blog-next/server/db/seeders/BlogSeeder.ts index a8a3ecc..2c07545 100644 --- a/apps/blog-next/server/db/seeders/BlogSeeder.ts +++ b/apps/blog-next/server/db/seeders/BlogSeeder.ts @@ -1,20 +1,34 @@ +import { hashPassword } from '@holo-js/auth' import { defineSeeder } from '@holo-js/db' import Post from '../../models/Post' import User from '../../models/User' import Category from '../../models/Category' import Tag from '../../models/Tag' +import Admin from '../../models/Admin' export default defineSeeder({ name: 'BlogSeeder', async run() { const timestamp = new Date('2026-04-26T09:00:00.000Z') + const userPassword = await hashPassword('secret') + const adminPassword = await hashPassword('admin-secret') const author = await User.unguarded(() => User.create({ name: 'Holo Editor', email: 'editor@example.com', - password: 'secret', + password: userPassword, + avatar: null, + email_verified_at: timestamp, + }), + ) + + await Admin.unguarded(() => + Admin.create({ + name: 'Super Admin', + email: 'super-admin@example.com', + password: adminPassword, avatar: null, email_verified_at: timestamp, }), diff --git a/apps/blog-next/server/holo-models.d.ts b/apps/blog-next/server/holo-models.d.ts index 7ffa216..481c34d 100644 --- a/apps/blog-next/server/holo-models.d.ts +++ b/apps/blog-next/server/holo-models.d.ts @@ -8,6 +8,7 @@ import type { RelationMap, } from '@holo-js/db' +type AdminTable = GeneratedSchemaTable<'admins'> type CategoryTable = GeneratedSchemaTable<'categories'> type CommentTable = GeneratedSchemaTable<'comments'> type PostTable = GeneratedSchemaTable<'posts'> @@ -15,6 +16,8 @@ type PostTagTable = GeneratedSchemaTable<'post_tags'> type TagTable = GeneratedSchemaTable<'tags'> type UserTable = GeneratedSchemaTable<'users'> +type AdminRelations = RelationMap + interface CategoryRelations extends RelationMap { readonly posts: HasManyRelationDefinition } @@ -40,6 +43,7 @@ interface UserRelations extends RelationMap { readonly comments: HasManyRelationDefinition } +type AdminModel = ModelReference type CategoryModel = ModelReference type CommentModel = ModelReference type PostModel = ModelReference @@ -48,6 +52,7 @@ type UserModel = ModelReference declare module '@holo-js/db' { interface RegisteredModels { + Admin: AdminModel Category: CategoryModel Comment: CommentModel Post: PostModel diff --git a/apps/blog-next/server/models/Admin.ts b/apps/blog-next/server/models/Admin.ts new file mode 100644 index 0000000..bb18e35 --- /dev/null +++ b/apps/blog-next/server/models/Admin.ts @@ -0,0 +1,6 @@ +import { defineModel } from '@holo-js/db' + +export default defineModel('admins', { + fillable: ['name', 'email', 'password', 'avatar'], + hidden: ['password'], +}) diff --git a/apps/blog-nuxt/app/app.vue b/apps/blog-nuxt/app/app.vue index 7fe6da3..35bfc86 100644 --- a/apps/blog-nuxt/app/app.vue +++ b/apps/blog-nuxt/app/app.vue @@ -1,8 +1,11 @@ + + + + diff --git a/apps/blog-nuxt/app/pages/super-admin/login.vue b/apps/blog-nuxt/app/pages/super-admin/login.vue new file mode 100644 index 0000000..3fbc242 --- /dev/null +++ b/apps/blog-nuxt/app/pages/super-admin/login.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/apps/blog-nuxt/config/auth.ts b/apps/blog-nuxt/config/auth.ts index c1ec02f..d460b04 100644 --- a/apps/blog-nuxt/config/auth.ts +++ b/apps/blog-nuxt/config/auth.ts @@ -10,20 +10,20 @@ export default defineAuthConfig({ driver: 'session', provider: 'users', }, - // admin: { - // driver: 'session', - // provider: 'admins', - // }, + admin: { + driver: 'session', + provider: 'admins', + }, }, providers: { users: { model: 'User', identifiers: ['email'], }, - // admins: { - // model: 'Admin', - // identifiers: ['email'], - // }, + admins: { + model: 'Admin', + identifiers: ['email'], + }, }, passwords: { users: { diff --git a/apps/blog-nuxt/server/api/auth/clerk/logout.post.ts b/apps/blog-nuxt/server/api/auth/clerk/logout.post.ts index ce54e6a..0b294f8 100644 --- a/apps/blog-nuxt/server/api/auth/clerk/logout.post.ts +++ b/apps/blog-nuxt/server/api/auth/clerk/logout.post.ts @@ -1,7 +1,26 @@ +import { provider } from '@holo-js/auth' import { logoutWithClerk } from '@holo-js/auth-clerk' import { createError, sendRedirect } from 'h3' +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + export default defineEventHandler(async (event) => { + let currentProvider: string | null + try { + currentProvider = await provider() + } catch (error) { + throw createError({ + statusCode: 500, + statusMessage: getErrorMessage(error), + }) + } + + if (currentProvider !== 'clerk') { + return await sendRedirect(event, '/', 303) + } + const result = await logoutWithClerk(event) if (!result.ok) { throw createError({ diff --git a/apps/blog-nuxt/server/api/auth/user.get.ts b/apps/blog-nuxt/server/api/auth/user.get.ts index 412b22f..68f10f8 100644 --- a/apps/blog-nuxt/server/api/auth/user.get.ts +++ b/apps/blog-nuxt/server/api/auth/user.get.ts @@ -1,9 +1,14 @@ -import { check, user } from '@holo-js/auth' +import auth, { check, provider, user } from '@holo-js/auth' + +export default defineEventHandler(async (event) => { + const query = getQuery(event) + const guard = typeof query.guard === 'string' ? query.guard : undefined + const guardAuth = guard ? auth.guard(guard) : undefined -export default defineEventHandler(async () => { return { - authenticated: await check(), - guard: 'web', - user: await user(), + authenticated: guardAuth ? await guardAuth.check() : await check(), + guard: guard ?? 'web', + provider: guardAuth ? await guardAuth.provider() : await provider(), + user: guardAuth ? await guardAuth.user() : await user(), } }) diff --git a/apps/blog-nuxt/server/api/auth/workos/logout.post.ts b/apps/blog-nuxt/server/api/auth/workos/logout.post.ts index 0dd8dac..8724d73 100644 --- a/apps/blog-nuxt/server/api/auth/workos/logout.post.ts +++ b/apps/blog-nuxt/server/api/auth/workos/logout.post.ts @@ -1,7 +1,26 @@ +import { provider } from '@holo-js/auth' import { logoutWithWorkos } from '@holo-js/auth-workos' import { createError, sendRedirect } from 'h3' +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + export default defineEventHandler(async (event) => { + let currentProvider: string | null + try { + currentProvider = await provider() + } catch (error) { + throw createError({ + statusCode: 500, + statusMessage: getErrorMessage(error), + }) + } + + if (currentProvider !== 'workos') { + return await sendRedirect(event, '/', 303) + } + const result = await logoutWithWorkos(event) if (!result.ok) { throw createError({ diff --git a/apps/blog-nuxt/server/api/super-admin/login.post.ts b/apps/blog-nuxt/server/api/super-admin/login.post.ts new file mode 100644 index 0000000..3651c98 --- /dev/null +++ b/apps/blog-nuxt/server/api/super-admin/login.post.ts @@ -0,0 +1,33 @@ +import auth from '@holo-js/auth' +import { validate } from '@holo-js/forms' + +import { loginForm } from '#shared/schemas/auth' + +export default defineEventHandler(async (event) => { + const submission = await validate(event, loginForm, { + throttle: 'login', + }) + + if (!submission.valid) { + const failure = submission.fail() + setResponseStatus(event, failure.status) + return failure + } + + const { data: session, error } = await auth.guard('admin').login(submission.data) + if (error) { + const failure = submission.fail({ + status: error.status, + errors: error.fields, + }) + + setResponseStatus(event, failure.status) + return failure + } + + return submission.success({ + message: 'Signed in as super admin.', + redirectTo: '/super-admin', + user: session.user, + }) +}) diff --git a/apps/blog-nuxt/server/api/super-admin/logout.post.ts b/apps/blog-nuxt/server/api/super-admin/logout.post.ts new file mode 100644 index 0000000..da436de --- /dev/null +++ b/apps/blog-nuxt/server/api/super-admin/logout.post.ts @@ -0,0 +1,13 @@ +import auth from '@holo-js/auth' + +export default defineEventHandler(async () => { + const admin = auth.guard('admin') + await admin.logout() + + return { + ok: true, + authenticated: false, + message: 'Signed out of super admin.', + user: await admin.user(), + } +}) diff --git a/apps/blog-nuxt/server/db/migrations/2026_04_25_044031_create_admins.ts b/apps/blog-nuxt/server/db/migrations/2026_04_25_044031_create_admins.ts new file mode 100644 index 0000000..e2eab0a --- /dev/null +++ b/apps/blog-nuxt/server/db/migrations/2026_04_25_044031_create_admins.ts @@ -0,0 +1,18 @@ +import { defineMigration, type MigrationContext } from '@holo-js/db' + +export default defineMigration({ + async up({ schema }: MigrationContext) { + await schema.createTable('admins', (table) => { + table.id() + table.string('name') + table.string('email').unique() + table.string('password').nullable() + table.string('avatar').nullable() + table.timestamp('email_verified_at').nullable() + table.timestamps() + }) + }, + async down({ schema }: MigrationContext) { + await schema.dropTable('admins') + }, +}) diff --git a/apps/blog-nuxt/server/db/seeders/BlogSeeder.ts b/apps/blog-nuxt/server/db/seeders/BlogSeeder.ts index b3442b4..74760b4 100644 --- a/apps/blog-nuxt/server/db/seeders/BlogSeeder.ts +++ b/apps/blog-nuxt/server/db/seeders/BlogSeeder.ts @@ -1,19 +1,31 @@ +import { hashPassword } from '@holo-js/auth' import { defineSeeder } from '@holo-js/db' import Post from '../../models/Post' import User from '../../models/User' import Category from '../../models/Category' import Tag from '../../models/Tag' +import Admin from '../../models/Admin' export default defineSeeder({ name: 'BlogSeeder', async run() { const timestamp = new Date('2026-04-26T09:00:00.000Z') + const userPassword = await hashPassword('secret') + const adminPassword = await hashPassword('admin-secret') const author = await User.unguarded(() => User.create({ name: 'Holo Editor', email: 'editor@example.com', - password: 'secret', + password: userPassword, + avatar: null, + email_verified_at: timestamp, + })) + + await Admin.unguarded(() => Admin.create({ + name: 'Super Admin', + email: 'super-admin@example.com', + password: adminPassword, avatar: null, email_verified_at: timestamp, })) diff --git a/apps/blog-nuxt/server/models/Admin.ts b/apps/blog-nuxt/server/models/Admin.ts new file mode 100644 index 0000000..bb18e35 --- /dev/null +++ b/apps/blog-nuxt/server/models/Admin.ts @@ -0,0 +1,6 @@ +import { defineModel } from '@holo-js/db' + +export default defineModel('admins', { + fillable: ['name', 'email', 'password', 'avatar'], + hidden: ['password'], +}) diff --git a/apps/blog-sveltekit/config/auth.ts b/apps/blog-sveltekit/config/auth.ts index c1ec02f..d460b04 100644 --- a/apps/blog-sveltekit/config/auth.ts +++ b/apps/blog-sveltekit/config/auth.ts @@ -10,20 +10,20 @@ export default defineAuthConfig({ driver: 'session', provider: 'users', }, - // admin: { - // driver: 'session', - // provider: 'admins', - // }, + admin: { + driver: 'session', + provider: 'admins', + }, }, providers: { users: { model: 'User', identifiers: ['email'], }, - // admins: { - // model: 'Admin', - // identifiers: ['email'], - // }, + admins: { + model: 'Admin', + identifiers: ['email'], + }, }, passwords: { users: { diff --git a/apps/blog-sveltekit/server/db/migrations/2026_04_25_044031_create_admins.ts b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044031_create_admins.ts new file mode 100644 index 0000000..e2eab0a --- /dev/null +++ b/apps/blog-sveltekit/server/db/migrations/2026_04_25_044031_create_admins.ts @@ -0,0 +1,18 @@ +import { defineMigration, type MigrationContext } from '@holo-js/db' + +export default defineMigration({ + async up({ schema }: MigrationContext) { + await schema.createTable('admins', (table) => { + table.id() + table.string('name') + table.string('email').unique() + table.string('password').nullable() + table.string('avatar').nullable() + table.timestamp('email_verified_at').nullable() + table.timestamps() + }) + }, + async down({ schema }: MigrationContext) { + await schema.dropTable('admins') + }, +}) diff --git a/apps/blog-sveltekit/server/db/seeders/BlogSeeder.ts b/apps/blog-sveltekit/server/db/seeders/BlogSeeder.ts index acc9e84..336c548 100644 --- a/apps/blog-sveltekit/server/db/seeders/BlogSeeder.ts +++ b/apps/blog-sveltekit/server/db/seeders/BlogSeeder.ts @@ -1,19 +1,31 @@ +import { hashPassword } from '@holo-js/auth' import { defineSeeder } from '@holo-js/db' import Post from '../../models/Post' import User from '../../models/User' import Category from '../../models/Category' import Tag from '../../models/Tag' +import Admin from '../../models/Admin' export default defineSeeder({ name: 'BlogSeeder', async run() { const timestamp = new Date('2026-04-26T09:00:00.000Z') + const userPassword = await hashPassword('secret') + const adminPassword = await hashPassword('admin-secret') const author = await User.unguarded(() => User.create({ name: 'Holo Editor', email: 'editor@example.com', - password: 'secret', + password: userPassword, + avatar: null, + email_verified_at: timestamp, + })) + + await Admin.unguarded(() => Admin.create({ + name: 'Super Admin', + email: 'super-admin@example.com', + password: adminPassword, avatar: null, email_verified_at: timestamp, })) diff --git a/apps/blog-sveltekit/server/holo-models.d.ts b/apps/blog-sveltekit/server/holo-models.d.ts index 7ffa216..481c34d 100644 --- a/apps/blog-sveltekit/server/holo-models.d.ts +++ b/apps/blog-sveltekit/server/holo-models.d.ts @@ -8,6 +8,7 @@ import type { RelationMap, } from '@holo-js/db' +type AdminTable = GeneratedSchemaTable<'admins'> type CategoryTable = GeneratedSchemaTable<'categories'> type CommentTable = GeneratedSchemaTable<'comments'> type PostTable = GeneratedSchemaTable<'posts'> @@ -15,6 +16,8 @@ type PostTagTable = GeneratedSchemaTable<'post_tags'> type TagTable = GeneratedSchemaTable<'tags'> type UserTable = GeneratedSchemaTable<'users'> +type AdminRelations = RelationMap + interface CategoryRelations extends RelationMap { readonly posts: HasManyRelationDefinition } @@ -40,6 +43,7 @@ interface UserRelations extends RelationMap { readonly comments: HasManyRelationDefinition } +type AdminModel = ModelReference type CategoryModel = ModelReference type CommentModel = ModelReference type PostModel = ModelReference @@ -48,6 +52,7 @@ type UserModel = ModelReference declare module '@holo-js/db' { interface RegisteredModels { + Admin: AdminModel Category: CategoryModel Comment: CommentModel Post: PostModel diff --git a/apps/blog-sveltekit/server/models/Admin.ts b/apps/blog-sveltekit/server/models/Admin.ts new file mode 100644 index 0000000..bb18e35 --- /dev/null +++ b/apps/blog-sveltekit/server/models/Admin.ts @@ -0,0 +1,6 @@ +import { defineModel } from '@holo-js/db' + +export default defineModel('admins', { + fillable: ['name', 'email', 'password', 'avatar'], + hidden: ['password'], +}) diff --git a/apps/blog-sveltekit/src/hooks.server.ts b/apps/blog-sveltekit/src/hooks.server.ts index 0271d4f..e7c57e3 100644 --- a/apps/blog-sveltekit/src/hooks.server.ts +++ b/apps/blog-sveltekit/src/hooks.server.ts @@ -10,4 +10,14 @@ export const handle = sequence( routes: ['/admin/*'], redirectTo: '/login', }), + guestOnly({ + routes: ['/super-admin/login'], + guard: 'admin', + redirectTo: '/super-admin', + }), + authOnly({ + routes: ['/super-admin'], + guard: 'admin', + redirectTo: '/super-admin/login', + }), ) diff --git a/apps/blog-sveltekit/src/routes/+layout.svelte b/apps/blog-sveltekit/src/routes/+layout.svelte index da96cef..0e6c458 100644 --- a/apps/blog-sveltekit/src/routes/+layout.svelte +++ b/apps/blog-sveltekit/src/routes/+layout.svelte @@ -7,8 +7,12 @@ let { data, children }: LayoutProps = $props() let isLoggingOut = $state(false) - const auth = useAuth({ initialUser: untrack(() => data?.auth?.user ?? null) }) + const auth = useAuth({ + initialProvider: untrack(() => data?.auth?.provider ?? null), + initialUser: untrack(() => data?.auth?.user ?? null), + }) const displayName = $derived(auth.user?.name ?? auth.user?.email ?? 'Account') + const usesHostedLogout = $derived(auth.provider === 'workos' || auth.provider === 'clerk') async function logout() { if (isLoggingOut) { @@ -39,15 +43,22 @@ blog-sveltekit Posts Admin + Super Admin {#if auth.authenticated} {displayName} - -
- -
-
- -
+ {#if !usesHostedLogout} + + {/if} + {#if auth.provider === 'workos'} +
+ +
+ {/if} + {#if auth.provider === 'clerk'} +
+ +
+ {/if} {:else} Login Register diff --git a/apps/blog-sveltekit/src/routes/api/auth/clerk/logout/+server.ts b/apps/blog-sveltekit/src/routes/api/auth/clerk/logout/+server.ts index 6bc08f9..d26f1be 100644 --- a/apps/blog-sveltekit/src/routes/api/auth/clerk/logout/+server.ts +++ b/apps/blog-sveltekit/src/routes/api/auth/clerk/logout/+server.ts @@ -1,7 +1,23 @@ import { redirect, type RequestHandler } from '@sveltejs/kit' +import { provider } from '@holo-js/auth' import { logoutWithClerk } from '@holo-js/auth-clerk' +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + export const POST = (async (event) => { + let currentProvider: string | null + try { + currentProvider = await provider() + } catch (error) { + return Response.json({ ok: false, error: getErrorMessage(error) }, { status: 422 }) + } + + if (currentProvider !== 'clerk') { + throw redirect(303, '/') + } + const result = await logoutWithClerk(event) if (!result.ok) { return Response.json(result, { status: 422 }) diff --git a/apps/blog-sveltekit/src/routes/api/auth/user/+server.ts b/apps/blog-sveltekit/src/routes/api/auth/user/+server.ts index ccf3056..d547918 100644 --- a/apps/blog-sveltekit/src/routes/api/auth/user/+server.ts +++ b/apps/blog-sveltekit/src/routes/api/auth/user/+server.ts @@ -1,10 +1,14 @@ import { json } from '@sveltejs/kit' -import { check, user } from '@holo-js/auth' +import auth, { check, provider, user } from '@holo-js/auth' + +export async function GET({ url }: { url: URL }) { + const guard = url.searchParams.get('guard') ?? undefined + const guardAuth = guard ? auth.guard(guard) : undefined -export async function GET() { return json({ - authenticated: await check(), - guard: 'web', - user: await user(), + authenticated: guardAuth ? await guardAuth.check() : await check(), + guard: guard ?? 'web', + provider: guardAuth ? await guardAuth.provider() : await provider(), + user: guardAuth ? await guardAuth.user() : await user(), }) } diff --git a/apps/blog-sveltekit/src/routes/api/auth/workos/logout/+server.ts b/apps/blog-sveltekit/src/routes/api/auth/workos/logout/+server.ts index f1e93ac..98c0249 100644 --- a/apps/blog-sveltekit/src/routes/api/auth/workos/logout/+server.ts +++ b/apps/blog-sveltekit/src/routes/api/auth/workos/logout/+server.ts @@ -1,7 +1,23 @@ import { redirect, type RequestHandler } from '@sveltejs/kit' +import { provider } from '@holo-js/auth' import { logoutWithWorkos } from '@holo-js/auth-workos' +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + export const POST = (async (event) => { + let currentProvider: string | null + try { + currentProvider = await provider() + } catch (error) { + return Response.json({ ok: false, error: getErrorMessage(error) }, { status: 422 }) + } + + if (currentProvider !== 'workos') { + throw redirect(303, '/') + } + const result = await logoutWithWorkos(event) if (!result.ok) { return Response.json(result, { status: 422 }) diff --git a/apps/blog-sveltekit/src/routes/api/super-admin/login/+server.ts b/apps/blog-sveltekit/src/routes/api/super-admin/login/+server.ts new file mode 100644 index 0000000..c834139 --- /dev/null +++ b/apps/blog-sveltekit/src/routes/api/super-admin/login/+server.ts @@ -0,0 +1,33 @@ +import { json } from '@sveltejs/kit' +import auth from '@holo-js/auth' +import { validate } from '@holo-js/forms' + +import { loginForm } from '$lib/schemas/auth' + +export async function POST({ request }: { request: Request }) { + const submission = await validate(request, loginForm, { + throttle: 'login', + }) + + if (!submission.valid) { + return json(submission.fail(), { + status: submission.fail().status, + }) + } + + const { data: session, error } = await auth.guard('admin').login(submission.data) + if (error) { + const failure = submission.fail({ + status: error.status, + errors: error.fields, + }) + + return json(failure, { status: failure.status }) + } + + return json(submission.success({ + message: 'Signed in as super admin.', + redirectTo: '/super-admin', + user: session.user, + })) +} diff --git a/apps/blog-sveltekit/src/routes/api/super-admin/logout/+server.ts b/apps/blog-sveltekit/src/routes/api/super-admin/logout/+server.ts new file mode 100644 index 0000000..972d42c --- /dev/null +++ b/apps/blog-sveltekit/src/routes/api/super-admin/logout/+server.ts @@ -0,0 +1,14 @@ +import { json } from '@sveltejs/kit' +import auth from '@holo-js/auth' + +export async function POST() { + const admin = auth.guard('admin') + await admin.logout() + + return json({ + ok: true, + authenticated: false, + message: 'Signed out of super admin.', + user: await admin.user(), + }) +} diff --git a/apps/blog-sveltekit/src/routes/super-admin/+page.server.ts b/apps/blog-sveltekit/src/routes/super-admin/+page.server.ts new file mode 100644 index 0000000..b699ac7 --- /dev/null +++ b/apps/blog-sveltekit/src/routes/super-admin/+page.server.ts @@ -0,0 +1,14 @@ +import { redirect } from '@sveltejs/kit' +import { auth } from '@holo-js/auth/sveltekit/server' + +export async function load() { + const currentAuth = await auth({ guard: 'admin' }) + + if (!currentAuth.authenticated) { + throw redirect(303, '/super-admin/login') + } + + return { + admin: currentAuth.user, + } +} diff --git a/apps/blog-sveltekit/src/routes/super-admin/+page.svelte b/apps/blog-sveltekit/src/routes/super-admin/+page.svelte new file mode 100644 index 0000000..26d1b39 --- /dev/null +++ b/apps/blog-sveltekit/src/routes/super-admin/+page.svelte @@ -0,0 +1,62 @@ + + +
+

Admin guard

+

Super Admin

+

Signed in as {displayName} through the admin guard.

+
+ +
+
+ + diff --git a/apps/blog-sveltekit/src/routes/super-admin/login/+page.svelte b/apps/blog-sveltekit/src/routes/super-admin/login/+page.svelte new file mode 100644 index 0000000..8f74ad3 --- /dev/null +++ b/apps/blog-sveltekit/src/routes/super-admin/login/+page.svelte @@ -0,0 +1,86 @@ + + +
+
+

Super Admin Sign In

+

Use an admin account to access the super admin area.

+
+ +
{ event.preventDefault(); form.submit() }}> + + + + + + + +
+ + {#if form.lastSubmission?.ok === true} +
+

Signed in as super admin.

+
+ {/if} +
+ + diff --git a/apps/docs/docs/auth/clerk.md b/apps/docs/docs/auth/clerk.md index 86c0b10..7240388 100644 --- a/apps/docs/docs/auth/clerk.md +++ b/apps/docs/docs/auth/clerk.md @@ -98,8 +98,13 @@ Logout clears the local Holo session, revokes the Clerk session through the Cler ```ts import { logoutWithClerk } from '@holo-js/auth-clerk' +import { provider } from '@holo-js/auth' export async function POST(request: Request) { + if (await provider() !== 'clerk') { + return Response.redirect(new URL('/', request.url), 303) + } + const result = await logoutWithClerk(request, { returnTo: '/login', }) @@ -112,6 +117,10 @@ export async function POST(request: Request) { } ``` +Use `provider()` on the server, or `useAuth().provider` in framework client code, to show the Clerk logout action only +when the current Holo session was created by Clerk. Calling `logoutWithClerk()` for a local, WorkOS, or other session +returns a typed failure because there is no Clerk session to revoke upstream. + ## Mapping Local Fields `completeClerkAuth()` passes a fully typed Clerk user to the optional mapper. Return any local user attributes you want Holo to save on create, update, or link. diff --git a/apps/docs/docs/auth/current-auth-client.md b/apps/docs/docs/auth/current-auth-client.md index 9932fc8..9f195d5 100644 --- a/apps/docs/docs/auth/current-auth-client.md +++ b/apps/docs/docs/auth/current-auth-client.md @@ -12,7 +12,7 @@ Use the auth client helper for your framework: Each framework auth entrypoint exposes `useAuth()`. The returned `user` is inferred as `HoloAuthUser | null`, so application code can read `auth.user`, `user.value`, or `auth.authenticated` without writing a local user shape type. -## `user` vs `refreshUser` +## `user`, `provider`, and `refreshUser` `user` is the current auth state the client already has. It is reactive in the framework adapters: @@ -20,14 +20,22 @@ Each framework auth entrypoint exposes `useAuth()`. The returned `user` is infer - Nuxt: `user.value` - SvelteKit: `auth.user` +`provider` identifies the current session source. Local Holo sessions return the local auth provider name, such as +`users` or `admins`. Hosted sessions return `workos` or `clerk`. Unauthenticated states return `null`. + +- Next.js: `auth.provider` +- Nuxt: `provider.value` +- SvelteKit: `auth.provider` + `refreshUser()` makes a new request to the current-user endpoint, updates that current auth state, and returns the fresh -user. +user. It also refreshes `provider`. Use `user` to render the current navigation, profile link, or authenticated UI. Use `refreshUser()` after an action that can change auth state, such as login, register, logout, switching guards, or updating the user's profile. ```ts const current = auth.user +const sessionSource = auth.provider const fresh = await auth.refreshUser() ``` @@ -175,7 +183,7 @@ export function AuthNav() { ``` @@ -300,13 +311,13 @@ If your app uses a different current-auth URL, pass `endpoint` to the framework ```tsx [Next.js] const auth = useAuth({ endpoint: '/api/me' }) - + {children} ``` ```vue [Nuxt] -const { authenticated, refreshUser, user } = await useAuth({ endpoint: '/api/me' }) +const { authenticated, provider, refreshUser, user } = await useAuth({ endpoint: '/api/me' }) ``` ```svelte [SvelteKit] @@ -323,50 +334,87 @@ const user = await refreshUser() ::: +For a non-default guard, pass `guard` to the framework auth helper. The client appends that guard to the +current-auth request query string, so `useAuth({ guard: 'admin' })` reads `/api/auth/user?guard=admin` by default. + +::: code-group + +```tsx [Next.js] +const auth = useAuth({ guard: 'admin' }) +``` + +```vue [Nuxt] +const { authenticated, refreshUser, user } = await useAuth({ guard: 'admin' }) +``` + +```svelte [SvelteKit] +const auth = useAuth({ guard: 'admin' }) +``` + +::: + +If you combine `guard` with a custom endpoint, the guard is still sent as a query string parameter: + +```ts +const auth = useAuth({ endpoint: '/api/me', guard: 'admin' }) +``` + ::: code-group ```ts [Next.js — app/api/auth/user/route.ts] -import { check, user } from '@holo-js/auth' +import auth, { check, provider, user } from '@holo-js/auth' + +export async function GET(request: Request) { + const guard = new URL(request.url).searchParams.get('guard') ?? undefined + const guardAuth = guard ? auth.guard(guard) : undefined -export async function GET() { return Response.json({ - authenticated: await check(), - guard: 'web', - user: await user(), + authenticated: guardAuth ? await guardAuth.check() : await check(), + guard: guard ?? 'web', + provider: guardAuth ? await guardAuth.provider() : await provider(), + user: guardAuth ? await guardAuth.user() : await user(), }) } ``` ```ts [Nuxt — server/api/auth/user.get.ts] -import { check, user } from '@holo-js/auth' +import auth, { check, provider, user } from '@holo-js/auth' + +export default defineEventHandler(async (event) => { + const query = getQuery(event) + const guard = typeof query.guard === 'string' ? query.guard : undefined + const guardAuth = guard ? auth.guard(guard) : undefined -export default defineEventHandler(async () => { return { - authenticated: await check(), - guard: 'web', - user: await user(), + authenticated: guardAuth ? await guardAuth.check() : await check(), + guard: guard ?? 'web', + provider: guardAuth ? await guardAuth.provider() : await provider(), + user: guardAuth ? await guardAuth.user() : await user(), } }) ``` ```ts [SvelteKit — src/routes/api/auth/user/+server.ts] import { json } from '@sveltejs/kit' -import { check, user } from '@holo-js/auth' +import auth, { check, provider, user } from '@holo-js/auth' + +export async function GET({ url }: { url: URL }) { + const guard = url.searchParams.get('guard') ?? undefined + const guardAuth = guard ? auth.guard(guard) : undefined -export async function GET() { return json({ - authenticated: await check(), - guard: 'web', - user: await user(), + authenticated: guardAuth ? await guardAuth.check() : await check(), + guard: guard ?? 'web', + provider: guardAuth ? await guardAuth.provider() : await provider(), + user: guardAuth ? await guardAuth.user() : await user(), }) } ``` ::: -For a named guard, pass `guard` to the framework auth helper and return that guard's state from the endpoint. The client -adds the guard to the current-auth request query string, so custom endpoints that support multiple guards should read -that value and call the matching server guard. +The default scaffolded endpoint supports both the default guard and named guards. If you write your own endpoint, keep +the same behavior when the app calls `useAuth({ guard: 'admin' })`. ## Types @@ -401,18 +449,21 @@ import { type HoloAuthUser } from '@holo-js/auth/client' - `useAuth()` - `user()` +- `provider()` - `refreshUser()` - `check()` ```ts -import { check, refreshUser, useAuth, user } from '@holo-js/auth/client' +import { check, provider, refreshUser, useAuth, user } from '@holo-js/auth/client' const auth = await useAuth() const current = auth.user +const sessionSource = auth.provider const authenticated = auth.check() const fresh = await auth.refreshUser() await user() +await provider() await check() await refreshUser() ``` diff --git a/apps/docs/docs/auth/workos.md b/apps/docs/docs/auth/workos.md index 0bc3cde..a56bef8 100644 --- a/apps/docs/docs/auth/workos.md +++ b/apps/docs/docs/auth/workos.md @@ -86,8 +86,13 @@ Logout clears the local Holo session and returns the hosted WorkOS logout URL. W ```ts import { logoutWithWorkos } from '@holo-js/auth-workos' +import { provider } from '@holo-js/auth' export async function POST(request: Request) { + if (await provider() !== 'workos') { + return Response.redirect(new URL('/', request.url), 303) + } + const result = await logoutWithWorkos(request) if (!result.ok) { @@ -98,6 +103,10 @@ export async function POST(request: Request) { } ``` +Use `provider()` on the server, or `useAuth().provider` in framework client code, to show the WorkOS logout action only +when the current Holo session was created by WorkOS. Calling `logoutWithWorkos()` for a local, Clerk, or other session +returns a typed failure because there is no WorkOS session to end upstream. + ## Mapping Local Fields `completeWorkosAuth()` passes a fully typed WorkOS user to the optional mapper. Return any local user attributes you want Holo to save on create, update, or link. diff --git a/packages/auth-clerk/src/index.ts b/packages/auth-clerk/src/index.ts index 52431bf..bc1d01a 100644 --- a/packages/auth-clerk/src/index.ts +++ b/packages/auth-clerk/src/index.ts @@ -1046,7 +1046,7 @@ function getHoloSessionIdFromRequest(request: Request): string | null { async function reuseExistingHoloSession( request: Request, - authenticated: Pick, + authenticated: Pick, ): Promise { const bindings = authRuntimeInternals.getRuntimeBindings() const sessionId = getHoloSessionIdFromRequest(request) @@ -1066,9 +1066,21 @@ async function reuseExistingHoloSession( return null } + const source = payload as typeof payload & { + readonly clerk?: { + readonly provider?: unknown + } + } + if (source.clerk && typeof source.clerk === 'object') { + if (typeof source.clerk.provider !== 'string' || source.clerk.provider !== authenticated.provider) { + return null + } + } + bindings.context.setCachedUser(authenticated.guard, authenticated.user) return Object.freeze({ guard: authenticated.guard, + provider: source.clerk && typeof source.clerk === 'object' ? 'clerk' : payload.provider, user: authenticated.user, sessionId, cookies: Object.freeze([]), diff --git a/packages/auth-clerk/tests/package.test.ts b/packages/auth-clerk/tests/package.test.ts index d492ba9..a7e0dd4 100644 --- a/packages/auth-clerk/tests/package.test.ts +++ b/packages/auth-clerk/tests/package.test.ts @@ -1,7 +1,7 @@ import { generateKeyPairSync, sign as signData } from 'node:crypto' import { afterEach, describe, expect, it, vi } from 'vitest' import { configureSessionRuntime, getSessionRuntime, resetSessionRuntime } from '../../session/src/runtime' -import { authRuntimeInternals, configureAuthRuntime, defineAuthConfig, logout, resetAuthRuntime } from '../../auth/src' +import { authRuntimeInternals, configureAuthRuntime, defineAuthConfig, logout, provider, resetAuthRuntime } from '../../auth/src' import type { AuthProviderAdapter } from '../../auth/src' import { ClerkAuthConflictError, @@ -1310,6 +1310,7 @@ describe('@holo-js/auth-clerk', () => { accessToken: 'callback-token', }, }) + await expect(provider()).resolves.toBe('clerk') expect(runtime.usersProvider.users.get(1)).toMatchObject({ email: 'callback@app.test', name: 'Callback User', diff --git a/packages/auth-workos/src/index.ts b/packages/auth-workos/src/index.ts index 333fbf7..aacb7f6 100644 --- a/packages/auth-workos/src/index.ts +++ b/packages/auth-workos/src/index.ts @@ -1043,7 +1043,7 @@ function getHoloSessionIdFromRequest(request: Request): string | null { async function reuseExistingHoloSession( request: Request, - authenticated: Pick, + authenticated: Pick, ): Promise { const bindings = authRuntimeInternals.getRuntimeBindings() const sessionId = getHoloSessionIdFromRequest(request) @@ -1063,9 +1063,21 @@ async function reuseExistingHoloSession( return null } + const source = payload as typeof payload & { + readonly workos?: { + readonly provider?: unknown + } + } + if (source.workos && typeof source.workos === 'object') { + if (typeof source.workos.provider !== 'string' || source.workos.provider !== authenticated.provider) { + return null + } + } + bindings.context.setCachedUser(authenticated.guard, authenticated.user) return Object.freeze({ guard: authenticated.guard, + provider: source.workos && typeof source.workos === 'object' ? 'workos' : payload.provider, user: authenticated.user, sessionId, cookies: Object.freeze([]), diff --git a/packages/auth-workos/tests/package.test.ts b/packages/auth-workos/tests/package.test.ts index 5ea61c3..3113c8b 100644 --- a/packages/auth-workos/tests/package.test.ts +++ b/packages/auth-workos/tests/package.test.ts @@ -1,7 +1,7 @@ import { generateKeyPairSync, sign as signData } from 'node:crypto' import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { configureSessionRuntime, getSessionRuntime, resetSessionRuntime } from '../../session/src/runtime' -import { authRuntimeInternals, configureAuthRuntime, defineAuthConfig, logout, resetAuthRuntime } from '../../auth/src' +import { authRuntimeInternals, configureAuthRuntime, defineAuthConfig, logout, provider, resetAuthRuntime } from '../../auth/src' import type { AuthProviderAdapter } from '../../auth/src' import { WorkosAuthConflictError, @@ -1022,6 +1022,7 @@ describe('@holo-js/auth-workos', () => { accessToken, }, }) + await expect(provider()).resolves.toBe('workos') expect(runtime.usersProvider.users.get(1)).toMatchObject({ email: 'callback@app.test', name: 'Callback User', diff --git a/packages/auth-workos/tests/tsconfig.json b/packages/auth-workos/tests/tsconfig.json new file mode 100644 index 0000000..dcba2a1 --- /dev/null +++ b/packages/auth-workos/tests/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.tests.json", + "include": [ + "./**/*.ts" + ], + "exclude": [ + "../node_modules", + "../dist", + "./**/*.type.test.ts" + ] +} diff --git a/packages/auth-workos/tsconfig.tests.json b/packages/auth-workos/tsconfig.tests.json new file mode 100644 index 0000000..ca6a796 --- /dev/null +++ b/packages/auth-workos/tsconfig.tests.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": [ + "src/**/*", + "tests/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "tests/**/*.type.test.ts" + ] +} diff --git a/packages/auth/src/client-runtime.ts b/packages/auth/src/client-runtime.ts index b0b4340..ae69b08 100644 --- a/packages/auth/src/client-runtime.ts +++ b/packages/auth/src/client-runtime.ts @@ -142,6 +142,10 @@ export async function user(options?: AuthClientRequestOptions): Promise { + return (await fetchCurrentUser(options)).provider +} + export async function useAuth( options?: AuthClientRequestOptions, ): Promise user(): Promise refreshUser(): Promise + provider(): Promise id(): Promise currentAccessToken(): Promise login( @@ -507,6 +508,7 @@ export interface AuthRuntimeFacade extends AuthFacade { export interface AuthEstablishedSession { readonly guard: string + readonly provider: string readonly user: AuthUser readonly sessionId: string readonly rememberToken?: string @@ -518,6 +520,7 @@ export interface AuthEstablishedSession { export interface CurrentAuthResponse { readonly authenticated: boolean readonly guard: string + readonly provider: string | null readonly user: AuthUser | null } diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 135ddaa..11371c5 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,4 +1,4 @@ -import { check, currentAccessToken, getAuthRuntime, hashPassword, id, impersonate, impersonateById, impersonation, login, loginUsing, loginUsingId, logout, needsPasswordRehash, refreshUser, register, requestPasswordReset, resendEmailVerification, resetPassword, sendEmailVerification, stopImpersonating, tokens, user, verification, verifyEmail, verifyPassword } from './runtime' +import { check, currentAccessToken, getAuthRuntime, hashPassword, id, impersonate, impersonateById, impersonation, login, loginUsing, loginUsingId, logout, needsPasswordRehash, provider, refreshUser, register, requestPasswordReset, resendEmailVerification, resetPassword, sendEmailVerification, stopImpersonating, tokens, user, verification, verifyEmail, verifyPassword } from './runtime' export { AUTH_ERROR_CODES, AuthError, defineAuthConfig, isAuthError } from './contracts' export { @@ -18,6 +18,7 @@ export { loginUsingId, logout, needsPasswordRehash, + provider, refreshUser, register, requestPasswordReset, @@ -95,6 +96,7 @@ const auth = Object.freeze({ check, user, refreshUser, + provider, id, currentAccessToken, hashPassword, diff --git a/packages/auth/src/next/client.ts b/packages/auth/src/next/client.ts index b2b4b14..97a0470 100644 --- a/packages/auth/src/next/client.ts +++ b/packages/auth/src/next/client.ts @@ -1,7 +1,7 @@ 'use client' import { createContext, createElement, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react' -import { refreshUser as refreshCurrentUser } from '../client' +import { authClientInternals } from '../client' import type { AuthClientRequestOptions, HoloAuthUser } from '../contracts' export type { HoloAuthUser } from '../contracts' @@ -9,11 +9,13 @@ export type { HoloAuthUser } from '../contracts' type UseAuthRequestOptions = Pick export type UseAuthOptions = UseAuthRequestOptions & { + readonly initialProvider?: string | null readonly initialUser?: HoloAuthUser | null } export type UseAuthResult = { readonly authenticated: boolean + readonly provider: string | null readonly user: HoloAuthUser | null readonly refreshUser: () => Promise } @@ -33,16 +35,25 @@ function useAuthState( options: UseAuthOptions = {}, stateOptions: { readonly refreshOnMount?: boolean } = {}, ): UseAuthResult { - const { initialUser, ...requestOptions } = options + const { initialProvider, initialUser, ...requestOptions } = options + const [currentProvider, setCurrentProvider] = useState(initialProvider ?? null) 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 + try { + const currentAuth = await authClientInternals.fetchCurrentUser(requestOptionsRef.current, { + force: true, + }) + setCurrentProvider(currentAuth.provider) + setCurrentUser(currentAuth.user) + return currentAuth.user + } catch (error) { + console.error('Failed to refresh auth user.', error) + throw error + } }, []) useEffect(() => { @@ -53,6 +64,7 @@ function useAuthState( return { authenticated: currentUser !== null, + provider: currentProvider, user: currentUser, refreshUser, } diff --git a/packages/auth/src/next/server.ts b/packages/auth/src/next/server.ts index c4e3228..d2258be 100644 --- a/packages/auth/src/next/server.ts +++ b/packages/auth/src/next/server.ts @@ -1,9 +1,11 @@ -import holoAuth, { user as currentUser } from '../index' +import holoAuth, { authRuntimeInternals, provider as currentProvider, user as currentUser } from '../index' import type { HoloAuthUser } from '../contracts' import { runWithNextAuthRequest, type NextAuthRequestLike } from './request-context' export type AuthState = { readonly authenticated: boolean + readonly guard: string + readonly provider: string | null readonly user: HoloAuthUser | null } @@ -80,14 +82,30 @@ function isSameUrl(left: URL, right: URL): boolean { } 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, + const guard = options.guard ?? authRuntimeInternals.getRuntimeBindings().config.defaults.guard + try { + const user = options.guard + ? await holoAuth.guard(guard).user() + : await currentUser() + const provider = options.guard + ? await holoAuth.guard(guard).provider() + : await currentProvider() + const clientUser = toClientAuthUser(user) + + return { + authenticated: clientUser !== null, + guard, + provider: clientUser ? provider : null, + user: clientUser, + } + } catch (error) { + console.warn('Failed to resolve Next.js auth state.', error) + return { + authenticated: false, + guard, + provider: null, + user: null, + } } } diff --git a/packages/auth/src/nuxt.ts b/packages/auth/src/nuxt.ts index 5ecc037..c196815 100644 --- a/packages/auth/src/nuxt.ts +++ b/packages/auth/src/nuxt.ts @@ -11,6 +11,7 @@ export type UseAuthOptions = { export type UseAuthResult = { readonly authenticated: Readonly<{ readonly value: boolean }> + readonly provider: { value: string | null } readonly user: { value: HoloAuthUser | null } readonly refreshUser: () => Promise } @@ -42,17 +43,21 @@ export async function useAuth(options: UseAuthOptions = {}): Promise(`${stateKey}:provider`, () => null) const currentUser = useState(`${stateKey}:user`, () => null) const authenticated = computed(() => currentUser.value !== null) const { data, refresh } = await useCurrentAuthFetch(requestUrl, stateKey) + currentProvider.value = data.value?.provider ?? null currentUser.value = data.value?.user ?? null return { authenticated, + provider: currentProvider, user: currentUser, async refreshUser() { await refresh() + currentProvider.value = data.value?.provider ?? null currentUser.value = data.value?.user ?? null return currentUser.value diff --git a/packages/auth/src/runtime.ts b/packages/auth/src/runtime.ts index 9bd6c9b..d97761c 100644 --- a/packages/auth/src/runtime.ts +++ b/packages/auth/src/runtime.ts @@ -798,6 +798,23 @@ function readSessionPayload( return Object.values(payloads)[0] ?? null } +function resolveSessionPayloadProvider(payload: SessionAuthPayload): string { + const source = payload as SessionAuthPayload & { + readonly clerk?: unknown + readonly workos?: unknown + } + + if (source.workos && typeof source.workos === 'object') { + return 'workos' + } + + if (source.clerk && typeof source.clerk === 'object') { + return 'clerk' + } + + return payload.provider +} + function writeSessionPayloads( currentData: Readonly>, payloads: SessionAuthPayloadMap, @@ -1703,6 +1720,7 @@ async function establishSessionForUser( return Object.freeze({ guard: options.guard, + provider: resolveSessionPayloadProvider(sessionPayload), user, sessionId: session.id, rememberToken, @@ -2116,6 +2134,9 @@ function createGuardFacade(guardName: string): AuthGuardFacade { refreshUser() { return refreshUserForGuard(guardName) }, + provider() { + return providerForGuard(guardName) + }, async id() { return (await userForGuard(guardName))?.id ?? null }, @@ -2187,6 +2208,9 @@ export function getAuthRuntime(): AuthRuntimeFacade { refreshUser() { return refreshUserForGuard(getDefaultGuardName()) }, + provider() { + return providerForGuard(getDefaultGuardName()) + }, async id() { return (await userForGuard(getDefaultGuardName()))?.id ?? null }, @@ -2297,6 +2321,28 @@ export async function refreshUserForGuard(guardName: string): Promise { + const bindings = getRuntimeBindings() + const guard = getGuardConfig(guardName) + const authenticatedUser = await resolveUserFromGuard(guardName) + if (!authenticatedUser) { + return null + } + + if (guard.driver === 'token') { + return readMarkedProvider(authenticatedUser) ?? null + } + + const sessionId = bindings.context.getSessionId(guardName) + if (!sessionId) { + return null + } + + const payload = readSessionPayload(await bindings.session.read(sessionId), guardName) + + return payload ? resolveSessionPayloadProvider(payload) : null +} + export async function check(): Promise { return getAuthRuntime().check() } @@ -2309,6 +2355,10 @@ export async function refreshUser(): Promise { return getAuthRuntime().refreshUser() } +export async function provider(): Promise { + return getAuthRuntime().provider() +} + export async function id(): Promise { return getAuthRuntime().id() } @@ -2360,15 +2410,24 @@ export async function stopImpersonating(): Promise { } export async function hashPassword(password: string): Promise { - return getAuthRuntime().hashPassword(password) + const bindings = getAuthRuntimeState().bindings + return bindings + ? getAuthRuntime().hashPassword(password) + : createDefaultPasswordHasher().hash(password) } export async function verifyPassword(password: string, digest: string): Promise { - return getAuthRuntime().verifyPassword(password, digest) + const bindings = getAuthRuntimeState().bindings + return bindings + ? getAuthRuntime().verifyPassword(password, digest) + : createDefaultPasswordHasher().verify(password, digest) } export async function needsPasswordRehash(digest: string): Promise { - return getAuthRuntime().needsPasswordRehash(digest) + const bindings = getAuthRuntimeState().bindings + return bindings + ? getAuthRuntime().needsPasswordRehash(digest) + : resolveNeedsPasswordRehash(createDefaultPasswordHasher(), digest) } export async function logout(): Promise { diff --git a/packages/auth/src/sveltekit/client.ts b/packages/auth/src/sveltekit/client.ts index b0d5cb3..6cf8f38 100644 --- a/packages/auth/src/sveltekit/client.ts +++ b/packages/auth/src/sveltekit/client.ts @@ -1,22 +1,29 @@ import { getContext, setContext } from 'svelte' import { createSubscriber } from 'svelte/reactivity' -import { refreshUser as refreshCurrentUser } from '../client' +import { authClientInternals } from '../client' import type { AuthClientRequestOptions, HoloAuthUser } from '../contracts' export type { HoloAuthUser } from '../contracts' export type UseAuthOptions = AuthClientRequestOptions & { + readonly initialProvider?: string | null readonly initialUser?: HoloAuthUser | null } export type UseAuthResult = { readonly authenticated: boolean + readonly provider: string | null readonly user: HoloAuthUser | null readonly refreshUser: () => Promise } const authContextKey = Symbol('holo-js.auth.client') +function hasExplicitUseAuthOptions(options: UseAuthOptions | undefined): options is UseAuthOptions { + return typeof options !== 'undefined' + && Object.values(options).some(value => typeof value !== 'undefined') +} + export function setAuthContext(auth: UseAuthResult): UseAuthResult { setContext(authContextKey, auth) return auth @@ -45,6 +52,7 @@ function trySetAuthContext(auth: UseAuthResult): void { class AuthClientState implements UseAuthResult { #notify: () => void = () => {} #pendingRefresh: Promise | undefined + #provider: string | null #user: HoloAuthUser | null readonly #subscribe = createSubscriber((update) => { @@ -56,9 +64,11 @@ class AuthClientState implements UseAuthResult { }) constructor( + initialProvider: string | null, initialUser: HoloAuthUser | null, private requestOptions: AuthClientRequestOptions, ) { + this.#provider = initialProvider this.#user = initialUser } @@ -72,17 +82,25 @@ class AuthClientState implements UseAuthResult { return this.#user } + get provider(): string | null { + this.#subscribe() + return this.#provider + } + async refreshUser(): Promise { if (this.#pendingRefresh) { return this.#pendingRefresh } - const refresh = refreshCurrentUser(this.requestOptions) - .then((user) => { - this.#user = user + const refresh = authClientInternals.fetchCurrentUser(this.requestOptions, { + force: true, + }) + .then((currentAuth) => { + this.#provider = currentAuth.provider + this.#user = currentAuth.user this.#notify() - return user + return currentAuth.user }) .finally(() => { this.#pendingRefresh = undefined @@ -99,9 +117,10 @@ class AuthClientState implements UseAuthResult { export function useAuth(options?: UseAuthOptions): UseAuthResult { const context = tryGetAuthContext() + const hasOptions = hasExplicitUseAuthOptions(options) const resolvedOptions = options ?? {} - const { initialUser = null, ...requestOptions } = resolvedOptions - if (context && typeof options?.initialUser === 'undefined') { + const { initialProvider = null, initialUser = null, ...requestOptions } = resolvedOptions + if (context && !hasOptions) { if (context instanceof AuthClientState) { context.setRequestOptions(requestOptions) } @@ -109,7 +128,7 @@ export function useAuth(options?: UseAuthOptions): UseAuthResult { return context } - const auth = new AuthClientState(initialUser, requestOptions) + const auth = new AuthClientState(initialProvider, initialUser, requestOptions) trySetAuthContext(auth) return auth diff --git a/packages/auth/src/sveltekit/server.ts b/packages/auth/src/sveltekit/server.ts index 5b38b7b..95141c5 100644 --- a/packages/auth/src/sveltekit/server.ts +++ b/packages/auth/src/sveltekit/server.ts @@ -1,9 +1,11 @@ import { AsyncLocalStorage } from 'node:async_hooks' -import holoAuth, { user as currentUser } from '../index' +import holoAuth, { authRuntimeInternals, provider as currentProvider, user as currentUser } from '../index' import type { AuthUserLike, HoloAuthUser } from '../contracts' export type AuthState = { readonly authenticated: boolean + readonly guard: string + readonly provider: string | null readonly user: HoloAuthUser | null } @@ -143,15 +145,22 @@ function isSameUrl(left: URL, right: URL): boolean { } export async function auth(options: AuthOptions = {}): Promise { + const guard = options.guard ?? authRuntimeInternals.getRuntimeBindings().config.defaults.guard let user: HoloAuthUser | null + let provider: string | null try { user = options.guard ? await holoAuth.guard(options.guard).user() : await currentUser() + provider = options.guard + ? await holoAuth.guard(options.guard).provider() + : await currentProvider() } catch (error) { console.warn('Failed to resolve SvelteKit auth state.', error) return { authenticated: false, + guard, + provider: null, user: null, } } @@ -160,6 +169,8 @@ export async function auth(options: AuthOptions = {}): Promise { return { authenticated: clientUser !== null, + guard, + provider: clientUser ? provider : null, user: clientUser, } } diff --git a/packages/auth/tests/contracts.type.test.ts b/packages/auth/tests/contracts.type.test.ts index e96f1b4..7040795 100644 --- a/packages/auth/tests/contracts.type.test.ts +++ b/packages/auth/tests/contracts.type.test.ts @@ -1,6 +1,6 @@ import { describe, expectTypeOf, it } from 'vitest' import auth, { AuthError, isAuthError, type AuthEmailVerificationConsumeErrorCode, type AuthEmailVerificationResendErrorCode, type AuthErrorCode, type AuthEstablishedSession, type AuthFailure, type AuthFieldErrors, type AuthGuardFacade, type AuthImpersonationState, type AuthLoginErrorCode, type AuthLogoutResult, type AuthPasswordResetConsumeErrorCode, type AuthPasswordResetRequestErrorCode, type AuthProviderAdapter, type AuthRegistrationErrorCode, type AuthResult, type AuthRuntimeBindings, type AuthUser, type CurrentAuthResponse, type EmailVerificationTokenResult, type getAuthRuntime, type HoloAuthUser, type register, type user, type verifyEmail } from '../src' -import clientAuth, { type refreshUser as refreshClientUser, type useAuth as clientUseAuth, type user as clientUser } from '../src/client' +import clientAuth, { type provider as clientProvider, 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' @@ -31,10 +31,12 @@ describe('@holo-js/auth typing', () => { type CurrentServerUser = Awaited> type CurrentClientUser = Awaited> type CurrentClientAuth = Awaited> + type CurrentClientProvider = Awaited> type CurrentNextAuth = ReturnType type CurrentNuxtAuth = Awaited> type CurrentSvelteKitAuth = ReturnType type RefreshedClientUser = Awaited> + type GuardProvider = Awaited> type GuardUser = Awaited> type GuardRefreshedUser = Awaited> type TrustedSession = Awaited> @@ -54,10 +56,15 @@ describe('@holo-js/auth typing', () => { readonly check: () => boolean readonly refreshUser: () => Promise }>() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() @@ -68,6 +75,7 @@ describe('@holo-js/auth typing', () => { expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() const adapter: AuthProviderAdapter<{ readonly id: number @@ -108,6 +116,7 @@ describe('@holo-js/auth typing', () => { expectTypeOf(adapter.serialize).returns.toEqualTypeOf() expectTypeOf(adapter.delete).toEqualTypeOf<((id: string | number) => Promise) | undefined>() expectTypeOf(auth.user).returns.toEqualTypeOf>() + expectTypeOf(auth.provider).returns.toEqualTypeOf>() expectTypeOf(auth.login).returns.toEqualTypeOf>>() expectTypeOf(auth.loginUsing).returns.toEqualTypeOf>() expectTypeOf(auth.loginUsingId).returns.toEqualTypeOf>() @@ -125,6 +134,7 @@ describe('@holo-js/auth typing', () => { expectTypeOf(auth.resendEmailVerification).parameter(0).toEqualTypeOf() expectTypeOf(auth.resendEmailVerification).returns.toEqualTypeOf>>>() expectTypeOf(clientAuth.user).returns.toEqualTypeOf>() + expectTypeOf(clientAuth.provider).returns.toEqualTypeOf>() expectTypeOf(clientAuth.useAuth).returns.toEqualTypeOf boolean readonly refreshUser: () => Promise diff --git a/packages/auth/tests/framework.test.ts b/packages/auth/tests/framework.test.ts index 8adf9fd..4f001f9 100644 --- a/packages/auth/tests/framework.test.ts +++ b/packages/auth/tests/framework.test.ts @@ -84,6 +84,61 @@ describe('@holo-js/auth framework helpers', () => { expect(refreshUser).not.toHaveBeenCalled() }) + it('does not reuse the SvelteKit auth context when explicit request options are passed', async () => { + type SvelteContextValue = unknown + + let storedContext: SvelteContextValue + const fetchCurrentUser = vi.fn(async () => ({ + authenticated: true, + guard: 'web', + provider: 'users', + user: { + id: 1, + email: 'ava@example.com', + name: 'Ava', + }, + })) + + vi.doMock('../src/client', () => ({ + authClientInternals: { + fetchCurrentUser, + }, + })) + vi.doMock('svelte', () => ({ + getContext() { + return storedContext + }, + setContext(_key: symbol, value: SvelteContextValue) { + storedContext = value + return value + }, + })) + vi.doMock('svelte/reactivity', () => ({ + createSubscriber() { + return () => {} + }, + })) + + const { useAuth } = await import('../src/sveltekit/client') + + const defaultAuth = useAuth({ + initialProvider: 'users', + initialUser: { + id: 1, + email: 'ava@example.com', + name: 'Ava', + }, + }) + const adminAuth = useAuth({ guard: 'admin' }) + + expect(adminAuth).not.toBe(defaultAuth) + expect(adminAuth.user).toBeNull() + + await defaultAuth.refreshUser() + + expect(fetchCurrentUser).toHaveBeenCalledWith({}, { force: true }) + }) + it('does not treat cross-origin Next redirects as self redirects', async () => { const { routeProtectionInternals } = await import('../src/next/server') diff --git a/packages/auth/tests/package.test.ts b/packages/auth/tests/package.test.ts index da680e3..f8fba87 100644 --- a/packages/auth/tests/package.test.ts +++ b/packages/auth/tests/package.test.ts @@ -39,6 +39,7 @@ import clientAuth, { authClientInternals, check as clientCheck, configureAuthClient, + provider as clientProvider, refreshUser as clientRefreshUser, resetAuthClient, useAuth as clientUseAuth, @@ -1099,6 +1100,17 @@ describe('@holo-js/auth package runtime', () => { await expect(needsPasswordRehash(digest)).resolves.toBe(false) }) + it('allows public password hashing helpers before auth runtime is configured', async () => { + resetAuthRuntime() + + const digest = await hashPassword('secret-secret') + + expect(digest).toMatch(/^scrypt\$/) + await expect(verifyPassword('secret-secret', digest)).resolves.toBe(true) + await expect(verifyPassword('wrong-secret', digest)).resolves.toBe(false) + await expect(needsPasswordRehash(digest)).resolves.toBe(false) + }) + it('keeps auth field failure helpers candidate-only and immutable', () => { const inheritedInput = Object.create({ password: 'secret-secret' }) as Readonly> expect(hasInputField(inheritedInput, 'password')).toBe(false) @@ -3079,15 +3091,19 @@ describe('@holo-js/auth package runtime', () => { expect(await auth.guard('web').user()).toMatchObject({ name: 'User Ava', }) + expect(await auth.guard('web').provider()).toBe('users') expect(await auth.guard('admin').user()).toMatchObject({ name: 'Admin Mina', }) + expect(await auth.guard('admin').provider()).toBe('admins') expect(await auth.user()).toMatchObject({ name: 'User Ava', }) + expect(await auth.provider()).toBe('users') const loggedOut = await auth.guard('admin').logout() expect(await auth.guard('admin').check()).toBe(false) + expect(await auth.guard('admin').provider()).toBeNull() expect(await auth.guard('web').check()).toBe(true) expect(loggedOut.cookies).toHaveLength(0) }) @@ -4062,10 +4078,12 @@ describe('@holo-js/auth package runtime', () => { runtime.context.setAccessToken('api', activeToken.plainTextToken) expect(await auth.guard('api').check()).toBe(true) expect(await auth.guard('api').user()).toMatchObject({ id: ava.id, email: ava.email }) + expect(await auth.guard('api').provider()).toBe('users') expect(await auth.guard('web').check()).toBe(true) runtime.context.setAccessToken('api', 'malformed-token') expect(await auth.guard('api').check()).toBe(false) + expect(await auth.guard('api').provider()).toBeNull() runtime.context.setAccessToken('api', `${activeToken.id}.bad-secret`) expect(await auth.guard('api').user()).toBeNull() @@ -4085,6 +4103,7 @@ describe('@holo-js/auth package runtime', () => { it('supports default and named client exports', () => { expect(clientAuth.check).toBe(clientCheck) + expect(clientAuth.provider).toBe(clientProvider) expect(clientAuth.useAuth).toBe(clientUseAuth) expect(clientAuth.user).toBe(clientUser) expect(clientAuth.refreshUser).toBe(clientRefreshUser) @@ -4103,6 +4122,7 @@ describe('@holo-js/auth package runtime', () => { return new Response(JSON.stringify({ authenticated: true, guard, + provider: guard === 'admin' ? 'admins' : 'users', user: { id: guard === 'admin' ? 2 : 1, email: guard === 'admin' ? 'admin@example.com' : 'ava@example.com', @@ -4139,6 +4159,7 @@ describe('@holo-js/auth package runtime', () => { expect(authState).toMatchObject({ authenticated: true, guard: 'web', + provider: 'users', user: { name: 'Ava', hit: 1, @@ -4154,6 +4175,7 @@ describe('@holo-js/auth package runtime', () => { }) expect(fetchMock).toHaveBeenCalledTimes(2) expect(await clientCheck()).toBe(true) + expect(await clientProvider()).toBe('users') const adminUser = await clientUser({ guard: 'admin' }) expect(adminUser).toMatchObject({ @@ -4197,6 +4219,7 @@ describe('@holo-js/auth package runtime', () => { return new Response(JSON.stringify({ authenticated: true, guard: 'web', + provider: 'users', user: { id: authorization === 'Bearer token-b' ? 2 : 1, email: authorization === 'Bearer token-b' ? 'b@example.com' : 'a@example.com', @@ -4562,6 +4585,7 @@ describe('@holo-js/auth package runtime', () => { return { authenticated: true, guard: 'admin', + provider: 'admins', user: { id: 2, email: 'admin@example.com', @@ -4597,6 +4621,7 @@ describe('@holo-js/auth package runtime', () => { })).resolves.toMatchObject({ authenticated: true, guard: 'admin', + provider: 'admins', user: { id: 2, header: 'token-a', @@ -4611,6 +4636,7 @@ describe('@holo-js/auth package runtime', () => { return new Response(JSON.stringify({ authenticated: true, guard: 'web', + provider: 'users', user: { id: 1, email: 'bound@example.com', @@ -4629,6 +4655,7 @@ describe('@holo-js/auth package runtime', () => { endpoint: 'https://example.com/api/auth/bound-user', })).resolves.toMatchObject({ authenticated: true, + provider: 'users', user: { email: 'bound@example.com', }, diff --git a/packages/auth/tests/tsconfig.json b/packages/auth/tests/tsconfig.json new file mode 100644 index 0000000..7c4a375 --- /dev/null +++ b/packages/auth/tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.tests.json", + "include": [ + "../src/**/*.d.ts", + "./**/*.ts" + ], + "exclude": [ + "../node_modules", + "../dist", + "./**/*.type.test.ts" + ] +} diff --git a/packages/cli/src/project/discovery-helpers.ts b/packages/cli/src/project/discovery-helpers.ts index 489a105..c510eab 100644 --- a/packages/cli/src/project/discovery-helpers.ts +++ b/packages/cli/src/project/discovery-helpers.ts @@ -254,8 +254,6 @@ export function isCliModelReference(value: unknown): value is CliModelReference && isRecord(value.definition) && value.definition.kind === 'model' && typeof value.definition.name === 'string' - && isRecord(value.definition.table) - && typeof value.definition.table.tableName === 'string' && typeof value.prune === 'function' } diff --git a/packages/cli/src/project/scaffold/framework-renderers.ts b/packages/cli/src/project/scaffold/framework-renderers.ts index 7d3e888..54108ce 100644 --- a/packages/cli/src/project/scaffold/framework-renderers.ts +++ b/packages/cli/src/project/scaffold/framework-renderers.ts @@ -7,6 +7,7 @@ import type { AuthInstallFeatures, ScaffoldedFile } from './types' type HostedAuthProvider = 'clerk' | 'workos' type HostedAuthProviderSpec = { + readonly provider: HostedAuthProvider readonly packageName: string readonly loginFunction: string readonly registerFunction: string @@ -16,6 +17,7 @@ type HostedAuthProviderSpec = { const HOSTED_AUTH_PROVIDERS = { workos: { + provider: 'workos', packageName: '@holo-js/auth-workos', loginFunction: 'loginWithWorkos', registerFunction: 'registerWithWorkos', @@ -23,6 +25,7 @@ const HOSTED_AUTH_PROVIDERS = { logoutFunction: 'logoutWithWorkos', }, clerk: { + provider: 'clerk', packageName: '@holo-js/auth-clerk', loginFunction: 'loginWithClerk', registerFunction: 'registerWithClerk', @@ -122,13 +125,18 @@ function renderNuxtHealthRoute(): string { function renderNuxtCurrentAuthRoute(): string { return [ - 'import { check, user } from \'@holo-js/auth\'', + 'import auth, { check, provider, user } from \'@holo-js/auth\'', + '', + 'export default defineEventHandler(async (event) => {', + ' const query = getQuery(event)', + ' const guard = typeof query.guard === \'string\' ? query.guard : undefined', + ' const guardAuth = guard ? auth.guard(guard) : undefined', '', - 'export default defineEventHandler(async () => {', ' return {', - ' authenticated: await check(),', - ' guard: \'web\',', - ' user: await user(),', + ' authenticated: guardAuth ? await guardAuth.check() : await check(),', + ' guard: guard ?? \'web\',', + ' provider: guardAuth ? await guardAuth.provider() : await provider(),', + ' user: guardAuth ? await guardAuth.user() : await user(),', ' }', '})', '', @@ -177,10 +185,22 @@ function renderNuxtHostedAuthCallbackRoute(spec: HostedAuthProviderSpec): string function renderNuxtHostedAuthLogoutRoute(spec: HostedAuthProviderSpec): string { return [ + 'import { provider } from \'@holo-js/auth\'', `import { ${spec.logoutFunction} } from '${spec.packageName}'`, 'import { createError, sendRedirect } from \'h3\'', '', 'export default defineEventHandler(async (event) => {', + ' let currentProvider: string | null', + ' try {', + ' currentProvider = await provider()', + ' } catch {', + ' return await sendRedirect(event, \'/\', 303)', + ' }', + '', + ` if (currentProvider !== '${spec.provider}') {`, + ' return await sendRedirect(event, \'/\', 303)', + ' }', + '', ` const result = await ${spec.logoutFunction}(event)`, ' if (!result.ok) {', ' throw createError({', @@ -318,13 +338,17 @@ function renderNextHealthRoute(): string { function renderNextCurrentAuthRoute(): string { return [ - 'import { check, user } from \'@holo-js/auth\'', + 'import auth, { check, provider, user } from \'@holo-js/auth\'', + '', + 'export async function GET(request: Request) {', + ' const guard = new URL(request.url).searchParams.get(\'guard\') ?? undefined', + ' const guardAuth = guard ? auth.guard(guard) : undefined', '', - 'export async function GET() {', ' return Response.json({', - ' authenticated: await check(),', - ' guard: \'web\',', - ' user: await user(),', + ' authenticated: guardAuth ? await guardAuth.check() : await check(),', + ' guard: guard ?? \'web\',', + ' provider: guardAuth ? await guardAuth.provider() : await provider(),', + ' user: guardAuth ? await guardAuth.user() : await user(),', ' })', '}', '', @@ -371,9 +395,21 @@ function renderNextHostedAuthCallbackRoute(spec: HostedAuthProviderSpec): string function renderNextHostedAuthLogoutRoute(spec: HostedAuthProviderSpec): string { return [ + 'import { provider } from \'@holo-js/auth\'', `import { ${spec.logoutFunction} } from '${spec.packageName}'`, '', 'export async function POST(request: Request) {', + ' let currentProvider: string | null', + ' try {', + ' currentProvider = await provider()', + ' } catch {', + ' return Response.redirect(new URL(\'/\', request.url), 303)', + ' }', + '', + ` if (currentProvider !== '${spec.provider}') {`, + ' return Response.redirect(new URL(\'/\', request.url), 303)', + ' }', + '', ` const result = await ${spec.logoutFunction}(request)`, ' if (!result.ok) {', ' return Response.json(result, { status: 422 })', @@ -594,13 +630,17 @@ function renderSvelteHealthRoute(): string { function renderSvelteCurrentAuthRoute(): string { return [ 'import { json } from \'@sveltejs/kit\'', - 'import { check, user } from \'@holo-js/auth\'', + 'import auth, { check, provider, user } from \'@holo-js/auth\'', + '', + 'export async function GET({ url }: { url: URL }) {', + ' const guard = url.searchParams.get(\'guard\') ?? undefined', + ' const guardAuth = guard ? auth.guard(guard) : undefined', '', - 'export async function GET() {', ' return json({', - ' authenticated: await check(),', - ' guard: \'web\',', - ' user: await user(),', + ' authenticated: guardAuth ? await guardAuth.check() : await check(),', + ' guard: guard ?? \'web\',', + ' provider: guardAuth ? await guardAuth.provider() : await provider(),', + ' user: guardAuth ? await guardAuth.user() : await user(),', ' })', '}', '', @@ -651,9 +691,21 @@ function renderSvelteHostedAuthCallbackRoute(spec: HostedAuthProviderSpec): stri function renderSvelteHostedAuthLogoutRoute(spec: HostedAuthProviderSpec): string { return [ 'import { redirect, type RequestHandler } from \'@sveltejs/kit\'', + 'import { provider } from \'@holo-js/auth\'', `import { ${spec.logoutFunction} } from '${spec.packageName}'`, '', 'export const POST = (async (event) => {', + ' let currentProvider: string | null', + ' try {', + ' currentProvider = await provider()', + ' } catch {', + ' throw redirect(303, \'/\')', + ' }', + '', + ` if (currentProvider !== '${spec.provider}') {`, + ' throw redirect(303, \'/\')', + ' }', + '', ` const result = await ${spec.logoutFunction}(event)`, ' if (!result.ok) {', ' return Response.json(result, { status: 422 })', diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 325c963..5af3e52 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -272,12 +272,12 @@ export async function getRuntimeEnvironment(projectRoot: string): Promise [table.tableName, table]), + )) +} + const manager = resolveRuntimeConnectionManagerOptions(payload.runtimeConfig) configureDB(manager) @@ -400,7 +406,7 @@ try { const executed = await createMigrationService(manager.connection(), migrations).migrate({}) await writeGeneratedSchemaArtifact(manager, payload.generatedSchemaOutputPath) - await preloadGeneratedSchema(manager, pathToFileURL(payload.generatedSchemaOutputPath).href) + syncGeneratedSchemaFromManager(manager) if (executed.length === 0) { console.log('No migrations were executed.') } else { diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index f2e8f27..b9b585f 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -1825,6 +1825,40 @@ APP_ENV=development expect(projectInternals.renderEnvFileContents(['', '\n'])).toBe('') }) + it('renders scaffolded current-auth routes that honor guard query parameters', () => { + const renderCurrentAuthRoute = (framework: 'next' | 'nuxt' | 'sveltekit', path: string): string => { + return projectInternals.renderFrameworkFiles({ + projectName: 'Guarded Auth App', + framework, + databaseDriver: 'sqlite', + packageManager: 'bun', + storageDefaultDisk: 'local', + optionalPackages: ['auth'], + }).find(file => file.path === path)?.contents ?? '' + } + + const nextCurrentAuthRoute = renderCurrentAuthRoute('next', 'app/api/auth/user/route.ts') + expect(nextCurrentAuthRoute).toContain('new URL(request.url).searchParams.get(\'guard\')') + expect(nextCurrentAuthRoute).toContain('const guardAuth = guard ? auth.guard(guard) : undefined') + expect(nextCurrentAuthRoute).toContain('authenticated: guardAuth ? await guardAuth.check() : await check()') + expect(nextCurrentAuthRoute).toContain('provider: guardAuth ? await guardAuth.provider() : await provider()') + expect(nextCurrentAuthRoute).toContain('user: guardAuth ? await guardAuth.user() : await user()') + + const nuxtCurrentAuthRoute = renderCurrentAuthRoute('nuxt', 'server/api/auth/user.get.ts') + expect(nuxtCurrentAuthRoute).toContain('const query = getQuery(event)') + expect(nuxtCurrentAuthRoute).toContain('const guardAuth = guard ? auth.guard(guard) : undefined') + expect(nuxtCurrentAuthRoute).toContain('authenticated: guardAuth ? await guardAuth.check() : await check()') + expect(nuxtCurrentAuthRoute).toContain('provider: guardAuth ? await guardAuth.provider() : await provider()') + expect(nuxtCurrentAuthRoute).toContain('user: guardAuth ? await guardAuth.user() : await user()') + + const svelteCurrentAuthRoute = renderCurrentAuthRoute('sveltekit', 'src/routes/api/auth/user/+server.ts') + expect(svelteCurrentAuthRoute).toContain('const guard = url.searchParams.get(\'guard\') ?? undefined') + expect(svelteCurrentAuthRoute).toContain('const guardAuth = guard ? auth.guard(guard) : undefined') + expect(svelteCurrentAuthRoute).toContain('authenticated: guardAuth ? await guardAuth.check() : await check()') + expect(svelteCurrentAuthRoute).toContain('provider: guardAuth ? await guardAuth.provider() : await provider()') + expect(svelteCurrentAuthRoute).toContain('user: guardAuth ? await guardAuth.user() : await user()') + }) + it('scaffolds a new project with cache support through the CLI', async () => { const targetRoot = await createTempDirectory() tempDirs.push(targetRoot) @@ -5598,9 +5632,9 @@ export default { factories: 'server/db/factories', commands: 'server/commands', }, - models: ['server/models/User.ts'], + models: ['server/models/User.ts', 'server/models/Admin.ts'], migrations: ['server/db/migrations/2026_01_01_000001_create_users.ts'], - seeders: ['server/db/seeders/UserSeeder.ts'], + seeders: ['server/db/seeders/UserSeeder.ts', 'server/db/seeders/AdminSeeder.ts'], } `) await writeProjectFile(projectRoot, 'config/database.ts', ` @@ -5641,6 +5675,15 @@ import { defineModel } from '@holo-js/db' export default defineModel('users', { fillable: ['name'], }) +`) + + await writeProjectFile(projectRoot, 'server/models/Admin.ts', ` +import '../../.holo-js/generated/schema.generated' +import { defineModel } from '@holo-js/db' + +export default defineModel('admins', { + fillable: ['name'], +}) `) await writeProjectFile(projectRoot, 'server/db/seeders/UserSeeder.ts', ` @@ -5653,6 +5696,20 @@ export default defineSeeder({ await User.query().where('name', '=', 'Alice').count() }, }) +`) + + await writeProjectFile(projectRoot, 'server/db/seeders/AdminSeeder.ts', ` +import Admin from '../../models/Admin' +import { defineSeeder } from '@holo-js/db' + +export default defineSeeder({ + name: 'AdminSeeder', + async run() { + await Admin.unguarded(() => Admin.create({ + name: 'Root', + })) + }, +}) `) await writeProjectFile(projectRoot, 'server/db/migrations/2026_01_01_000001_create_users.ts', ` @@ -5664,8 +5721,13 @@ export default defineMigration({ table.id() table.string('name') }) + await schema.createTable('admins', (table) => { + table.id() + table.string('name') + }) }, async down({ schema }) { + await schema.dropTable('admins') await schema.dropTable('users') }, }) @@ -5673,10 +5735,13 @@ export default defineMigration({ const fresh = runCliProcess(projectRoot, ['migrate:fresh', '--seed']) expect(fresh.status, fresh.stderr || fresh.stdout).toBe(0) - expect(fresh.stdout).toMatch(/Seeders executed:\s+UserSeeder/) + expect(fresh.stdout).toContain('Seeders executed:') + expect(fresh.stdout).toContain('UserSeeder') + expect(fresh.stdout).toContain('AdminSeeder') const generated = await readFile(join(projectRoot, '.holo-js/generated/schema.generated.ts'), 'utf8') expect(generated).toContain('"name": column.string()') + expect(generated).toContain('export const admins = defineGeneratedTable("admins", {') }, 30000) it('runs runtime commands without requiring the app to install @holo-js/db directly', async () => { diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 6e6c26d..844c0d8 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -235,6 +235,7 @@ export type { HasManyRelationDefinition, HasOneRelationDefinition, ModelCollection, + StaticModelApi, } from './model' export type { NormalizedHoloProjectConfig, @@ -317,10 +318,14 @@ export type { export type { DefineModelOptions, EntityWithLoaded, + BuiltInCastName, + BuiltInCastString, + CastableDefinition, RegisteredModelName, RegisteredModelReference, RegisteredModels, ModelAttributeKey, + ModelCastDefinition, ModelDefinition, DynamicRelationResolver, ModelInsertPayload, @@ -328,6 +333,7 @@ export type { ModelReference, RelationDefinition, RelationMap, + EnumCastDefinition, ModelScopeArgs, ModelScopeMap, ModelTrait, @@ -344,9 +350,11 @@ export type { } from './model' export type { CursorPaginatedResult, + CursorPaginationOptions, DeleteQueryPlan, InsertQueryPlan, PaginationMeta, + PaginationOptions, PaginatedResult, QueryDirection, QueryOperator, diff --git a/packages/db/src/model/ModelRegistry.ts b/packages/db/src/model/ModelRegistry.ts index d6bb8ed..bf6aa1b 100644 --- a/packages/db/src/model/ModelRegistry.ts +++ b/packages/db/src/model/ModelRegistry.ts @@ -5,6 +5,41 @@ function resolveDefinition(reference: ModelDefinitionLike): AnyModelDefinition { return 'definition' in reference ? reference.definition : reference } +function isMissingGeneratedSchemaModelError(error: unknown): boolean { + return error instanceof Error + && error.message.includes('is not present in the generated schema registry') +} + +function tryGetDefinitionTableName(definition: AnyModelDefinition): string | undefined { + try { + return definition.table.tableName + } catch (error) { + if (isMissingGeneratedSchemaModelError(error)) { + return undefined + } + + throw error + } +} + +function definitionsReferToSameModel(left: AnyModelDefinition, right: AnyModelDefinition): boolean { + if (left === right) { + return true + } + + const leftTableName = tryGetDefinitionTableName(left) + const rightTableName = tryGetDefinitionTableName(right) + if (!leftTableName || !rightTableName) { + return left.name === right.name + && left.primaryKey === right.primaryKey + && left.morphClass === right.morphClass + } + + return leftTableName === rightTableName + && left.primaryKey === right.primaryKey + && left.morphClass === right.morphClass +} + const globalModels = new Map() export class ModelRegistry { @@ -13,7 +48,7 @@ export class ModelRegistry { register(reference: ModelDefinitionLike): AnyModelDefinition { const definition = resolveDefinition(reference) const existing = this.models.get(definition.name) - if (existing && existing !== definition) { + if (existing && !definitionsReferToSameModel(existing, definition)) { throw new DatabaseError(`Model "${definition.name}" is already registered.`, 'DUPLICATE_MODEL') } @@ -47,14 +82,7 @@ export function registerGlobalModel(reference: ModelDefinitionLike): ModelDefini const existing = globalModels.get(definition.name) if (existing) { const existingDefinition = resolveDefinition(existing) - const isSameDefinition = existingDefinition === definition - || ( - existingDefinition.table.tableName === definition.table.tableName - && existingDefinition.primaryKey === definition.primaryKey - && existingDefinition.morphClass === definition.morphClass - ) - - if (!isSameDefinition) { + if (!definitionsReferToSameModel(existingDefinition, definition)) { throw new DatabaseError(`Model "${definition.name}" is already registered globally.`, 'DUPLICATE_MODEL') } diff --git a/packages/db/src/model/defineModel.ts b/packages/db/src/model/defineModel.ts index 915e3e2..b21356e 100644 --- a/packages/db/src/model/defineModel.ts +++ b/packages/db/src/model/defineModel.ts @@ -159,13 +159,12 @@ function defineModelFromGeneratedTableName< tableName: TName, options: DefineModelOptions, TScopes, TRelations> = {}, ): StaticModelApi, TScopes, TRelations> { - const resolvedAtDefinition = resolveGeneratedModelTable(tableName) as GeneratedSchemaTable const inferredName = options.name ?? inferModelName(tableName) const relations = { ...(options.relations ?? {}) } as TRelations const touches = validateTouches(inferredName, relations, options.touches ?? []) const resolveTableDefinition = (): GeneratedSchemaTable => ( - resolveGeneratedModelTable(tableName) as GeneratedSchemaTable + resolveGeneratedModelTable(tableName, options.connectionName) as GeneratedSchemaTable ) const resolvePrimaryKeyFromTable = ( table: GeneratedSchemaTable, @@ -209,8 +208,6 @@ function defineModelFromGeneratedTableName< return resolveUniqueIdFromTable(resolveTable()) } - validateUniqueIdConfig(resolvedAtDefinition, inferredName, resolveUniqueId()) - const definition = { kind: 'model' as const, name: inferredName, diff --git a/packages/db/src/model/defineModelHelpers.ts b/packages/db/src/model/defineModelHelpers.ts index 5bff465..1a6d0f0 100644 --- a/packages/db/src/model/defineModelHelpers.ts +++ b/packages/db/src/model/defineModelHelpers.ts @@ -1,5 +1,6 @@ import { singularize } from 'inflection' -import { SchemaError } from '../core/errors' +import { ConfigurationError, SchemaError } from '../core/errors' +import { DB } from '../facade/DB' import { TableDefinitionBuilder } from '../schema/TableDefinitionBuilder' import { getGeneratedTableDefinition } from '../schema/generated' import type { ColumnInput } from '../schema/columns' @@ -50,8 +51,25 @@ export function inferPrimaryKey(table: TTable): return primaryKey.name as Extract } -export function resolveGeneratedModelTable(tableName: string): TableDefinition { - const table = getGeneratedTableDefinition(tableName) +function getRuntimeSchemaTable(tableName: string, connectionName?: string): TableDefinition | undefined { + try { + return DB.getManager().connection(connectionName).getSchemaRegistry().get(tableName) + } catch (error) { + if ( + error instanceof ConfigurationError + && error.code === 'CONFIGURATION_ERROR' + && error.message.includes('ConnectionManager') + && error.message.includes('not configured') + ) { + return undefined + } + + throw error + } +} + +export function resolveGeneratedModelTable(tableName: string, connectionName?: string): TableDefinition { + const table = getGeneratedTableDefinition(tableName) ?? getRuntimeSchemaTable(tableName, connectionName) if (!table) { throw new SchemaError( `Model "${tableName}" is not present in the generated schema registry. Run "holo migrate" to refresh the internal generated schema metadata.`, diff --git a/packages/db/src/model/index.ts b/packages/db/src/model/index.ts index c3e132a..587bc67 100644 --- a/packages/db/src/model/index.ts +++ b/packages/db/src/model/index.ts @@ -41,9 +41,13 @@ export { } from './uniqueIds' export type { ModelCollection } from './collection' export type { Model } from './Entity' +export type { StaticModelApi } from './staticModelApi' export type { BelongsToManyRelationDefinition, BelongsToRelationDefinition, + BuiltInCastName, + BuiltInCastString, + CastableDefinition, DefineModelOptions, EntityWithLoaded, ModelAttributeKey, @@ -54,6 +58,7 @@ export type { ModelInsertPayload, ModelRecord, ModelReference, + ModelCastDefinition, DynamicRelationResolver, EmptyScopeMap, EnumCastDefinition, diff --git a/packages/db/tests/model-core.test.ts b/packages/db/tests/model-core.test.ts index 20ebd1b..f5799d2 100644 --- a/packages/db/tests/model-core.test.ts +++ b/packages/db/tests/model-core.test.ts @@ -441,11 +441,15 @@ describe('model core slice', () => { expect(User.definition.timestamps).toBe(true) }) - it('fails fast when defineModel(tableName, options) is called before the generated schema is registered', () => { - expect(() => defineModel('users', { + it('defers defineModel(tableName, options) generated schema lookups until the model is used', () => { + const User = defineModel('users', { fillable: ['name'], timestamps: true, - })).toThrow( + }) + + expect(User.definition.name).toBe('User') + expect(User.definition.fillable).toEqual(['name']) + expect(() => User.getTableName()).toThrow( 'Model "users" is not present in the generated schema registry. Run "holo migrate" to refresh the internal generated schema metadata.', ) }) @@ -546,12 +550,39 @@ describe('model core slice', () => { ) }) - it('throws immediately when defineModel(tableName) references a missing generated schema table', () => { - expect(() => defineModel('missing_users')).toThrow( + it('defers generated schema table resolution until the model is used', () => { + const MissingUser = defineModel('missing_users') + + expect(MissingUser.definition.name).toBe('MissingUser') + expect(MissingUser.definition.morphClass).toBe('MissingUser') + expect(() => MissingUser.getTableName()).toThrow( 'Model "missing_users" is not present in the generated schema registry. Run "holo migrate" to refresh the internal generated schema metadata.', ) }) + it('resolves deferred generated models from the configured runtime schema registry', () => { + const admins = defineTable('admins', { + id: column.id(), + name: column.string(), + }) + configureDB(createConnectionManager({ + defaultConnection: 'default', + connections: { + default: createDatabase({ + connectionName: 'default', + adapter: new InMemoryAdapter({}, {}), + dialect: createDialect('sqlite'), + }), + }, + })) + DB.connection().getSchemaRegistry().replace(admins) + + const Admin = defineModel('admins') + + expect(Admin.getTableName()).toBe('admins') + expect(Admin.definition.table.columns).toHaveProperty('name') + }) + it('aligns default polymorphic relation columns with builder-generated morph columns', () => { const tags = defineTable('tags', { id: column.id() }) const images = defineTable('images', { diff --git a/tests/example-app-auth-flow.mjs b/tests/example-app-auth-flow.mjs index 4068c3c..e938966 100644 --- a/tests/example-app-auth-flow.mjs +++ b/tests/example-app-auth-flow.mjs @@ -248,6 +248,9 @@ export async function assertExampleAppAuthFlow({ assert.match(loginPage.text, /Continue with WorkOS/i) assertGuestNav(loginPage.text) + const superAdminLoginPage = await fetchAuthText('/super-admin/login') + assert.match(superAdminLoginPage.text, /Super Admin Sign In/i) + const forgotPasswordPage = await fetchAuthText('/forgot-password') assert.match(forgotPasswordPage.text, /Forgot password/i) @@ -257,6 +260,10 @@ export async function assertExampleAppAuthFlow({ assertRedirectsTo(await fetchAuthText('/admin/posts', { allowFailure: true, }), '/login') + + assertRedirectsTo(await fetchAuthText('/super-admin', { + allowFailure: true, + }), '/super-admin/login') } const initialUser = await fetchAuthJson('/api/auth/user') @@ -535,6 +542,82 @@ export async function assertExampleAppAuthFlow({ jar: authenticatedJar, }) assert.match(adminPostsPage.text, /Designing the Example App Roadmap/i) + + assertRedirectsTo(await fetchAuthText('/super-admin', { + jar: authenticatedJar, + allowFailure: true, + }), '/super-admin/login') + } + + const adminJar = createCookieJar() + const adminLogin = await fetchAuthJson('/api/super-admin/login', { + fields: { + email: 'super-admin@example.com', + password: 'admin-secret', + }, + headers: { + 'x-forwarded-for': '127.0.0.226', + 'x-real-ip': '127.0.0.226', + }, + jar: adminJar, + }) + assert.equal(adminLogin.json.ok, true) + assert.equal(adminLogin.json.data?.message, 'Signed in as super admin.') + assert.equal(adminLogin.json.data?.redirectTo, '/super-admin') + assert.equal(adminLogin.json.data?.user?.email, 'super-admin@example.com') + assert.ok(listSetCookieHeaders(adminLogin.response).length > 0) + + const adminGuardUser = await fetchAuthJson('/api/auth/user?guard=admin', { + jar: adminJar, + }) + assert.equal(adminGuardUser.json.authenticated, true) + assert.equal(adminGuardUser.json.guard, 'admin') + assert.equal(adminGuardUser.json.user?.email, 'super-admin@example.com') + assert.equal(adminGuardUser.json.user?.name, 'Super Admin') + + const adminDefaultGuardUser = await fetchAuthJson('/api/auth/user', { + jar: adminJar, + }) + assert.equal(adminDefaultGuardUser.json.authenticated, false) + assert.equal(adminDefaultGuardUser.json.guard, 'web') + assert.equal(adminDefaultGuardUser.json.user, null) + + if (checkPages) { + const superAdminPage = await fetchAuthText('/super-admin', { + jar: adminJar, + }) + assert.match(superAdminPage.text, /Super Admin/i) + assert.match(superAdminPage.text, /admin guard/i) + assert.match(superAdminPage.text, /Sign out of super admin/i) + + assertRedirectsTo(await fetchAuthText('/super-admin/login', { + jar: adminJar, + allowFailure: true, + }), '/super-admin') + } + + const adminLogout = await fetchAuthJson('/api/super-admin/logout', { + method: 'POST', + jar: adminJar, + }) + assert.equal(adminLogout.json.ok, true) + assert.equal(adminLogout.json.authenticated, false) + assert.equal(adminLogout.json.message, 'Signed out of super admin.') + assert.equal(adminLogout.json.user, null) + assert.ok(listSetCookieHeaders(adminLogout.response).length > 0) + + const adminAfterLogout = await fetchAuthJson('/api/auth/user?guard=admin', { + jar: adminJar, + }) + assert.equal(adminAfterLogout.json.authenticated, false) + assert.equal(adminAfterLogout.json.guard, 'admin') + assert.equal(adminAfterLogout.json.user, null) + + if (checkPages) { + assertRedirectsTo(await fetchAuthText('/super-admin', { + jar: adminJar, + allowFailure: true, + }), '/super-admin/login') } const authenticatedSessionCookie = authenticatedJar.header()