diff --git a/apps/blog-next/app/api-token-posts/page.tsx b/apps/blog-next/app/api-token-posts/page.tsx new file mode 100644 index 00000000..8cb27935 --- /dev/null +++ b/apps/blog-next/app/api-token-posts/page.tsx @@ -0,0 +1,7 @@ +import { TokenPostsClient } from './token-posts-client' + +export const dynamic = 'force-dynamic' + +export default function ApiTokenPostsPage() { + return +} diff --git a/apps/blog-next/app/api-token-posts/token-posts-client.tsx b/apps/blog-next/app/api-token-posts/token-posts-client.tsx new file mode 100644 index 00000000..8277650d --- /dev/null +++ b/apps/blog-next/app/api-token-posts/token-posts-client.tsx @@ -0,0 +1,129 @@ +'use client' + +import { type FormEvent, useState } from 'react' + +type JsonResult = { + readonly status: number + readonly payload: unknown +} + +function isRecord(value: unknown): value is Readonly> { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function getStringField(value: unknown, field: string): string { + if (!isRecord(value)) { + return '' + } + + const fieldValue = value[field] + return typeof fieldValue === 'string' ? fieldValue : '' +} + +async function readJson(response: Response): Promise { + try { + return await response.json() + } catch { + return { + ok: false, + message: 'Response was not valid JSON.', + } + } +} + +export function TokenPostsClient() { + const [tokenResult, setTokenResult] = useState(null) + const [postsResult, setPostsResult] = useState(null) + const [creatingToken, setCreatingToken] = useState(false) + const [fetchingPosts, setFetchingPosts] = useState(false) + const generatedToken = getStringField(tokenResult?.payload, 'token') + + async function createToken(event: FormEvent) { + event.preventDefault() + setCreatingToken(true) + setPostsResult(null) + + try { + const response = await fetch('/api/v1/tokens', { + method: 'POST', + body: new FormData(event.currentTarget), + }) + + setTokenResult({ + status: response.status, + payload: await readJson(response), + }) + } finally { + setCreatingToken(false) + } + } + + async function fetchPosts(event: FormEvent) { + event.preventDefault() + setFetchingPosts(true) + + const formData = new FormData(event.currentTarget) + const token = String(formData.get('token') ?? '').trim() + + try { + const response = await fetch('/api/v1/posts', { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }) + + setPostsResult({ + status: response.status, + payload: await readJson(response), + }) + } finally { + setFetchingPosts(false) + } + } + + return ( +
+
+

API token posts

+

Generate a bearer token from credentials, then use it to fetch protected posts.

+
+ +
+

Create token

+ + + +
+ + {tokenResult ? ( +
+

Token response ({tokenResult.status})

+ {generatedToken ? ( + + {/if} +
{JSON.stringify(tokenResult.payload, null, 2)}
+
+ {/if} + +
+

Fetch posts

+ + +
+ + {#if postsResult} +
+

Posts response ({postsResult.status})

+
{JSON.stringify(postsResult.payload, null, 2)}
+
+ {/if} +
+ + diff --git a/apps/blog-sveltekit/src/routes/api/v1/posts/+server.ts b/apps/blog-sveltekit/src/routes/api/v1/posts/+server.ts new file mode 100644 index 00000000..82a543f0 --- /dev/null +++ b/apps/blog-sveltekit/src/routes/api/v1/posts/+server.ts @@ -0,0 +1,35 @@ +import { json } from '@sveltejs/kit' +import auth from '@holo-js/auth' + +import Post from '../../../../../server/models/Post' + +export async function GET() { + const currentUser = await auth.guard('api').user() + + if (!currentUser) { + return json({ + ok: false, + message: 'Unauthenticated.', + }, { status: 401 }) + } + + const userId = currentUser.id + + if (!currentUser.can('posts.read')) { + return json({ + ok: false, + message: 'Forbidden.', + }, { status: 403 }) + } + + const posts = await Post + .with('category', 'tags') + .where('user_id', userId) + .orderBy('published_at', 'desc') + .get() + + return json({ + ok: true, + posts, + }) +} diff --git a/apps/blog-sveltekit/src/routes/api/v1/tokens/+server.ts b/apps/blog-sveltekit/src/routes/api/v1/tokens/+server.ts new file mode 100644 index 00000000..94fb1b13 --- /dev/null +++ b/apps/blog-sveltekit/src/routes/api/v1/tokens/+server.ts @@ -0,0 +1,41 @@ +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) { + const failure = submission.fail() + return json(failure, { + status: failure.status, + }) + } + + const { data: token, error } = await auth.guard('api').login({ + ...submission.data, + abilities: ['posts.read'], + }) + + if (error) { + return json({ + ok: false, + message: 'Invalid credentials.', + }, { status: 401 }) + } + + return json({ + ok: true, + token: token.plainTextToken, + tokenId: token.id, + abilities: token.abilities, + }, { + headers: { + 'Cache-Control': 'no-store', + }, + }) +} diff --git a/apps/blog-sveltekit/tests/run.mjs b/apps/blog-sveltekit/tests/run.mjs index 2eca0342..1515dd8b 100644 --- a/apps/blog-sveltekit/tests/run.mjs +++ b/apps/blog-sveltekit/tests/run.mjs @@ -8,6 +8,7 @@ import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { DEFAULT_SESSION_COOKIE_NAME } from '@holo-js/config' import { assertExampleAppAuthFlow } from '../../../tests/example-app-auth-flow.mjs' +import { assertExampleAppTokenAuthFlow } from '../../../tests/example-app-token-auth-flow.mjs' const cwd = process.cwd() const configPath = join(cwd, 'config/app.ts') @@ -307,6 +308,10 @@ try { appName: 'blog-sveltekit', sessionCookieName: DEFAULT_SESSION_COOKIE_NAME, }) + await assertExampleAppTokenAuthFlow({ + baseUrl: devUrl, + expectedTitle: 'Shipping a Real Holo Blog on SvelteKit', + }) await writeFile(configPath, originalConfig.replace("name: env('APP_NAME', 'blog-sveltekit')", "name: env('APP_NAME', 'blog-sveltekit-updated')")) await new Promise(resolve => setTimeout(resolve, 3000)) diff --git a/apps/docs/docs/auth/guards-and-providers.md b/apps/docs/docs/auth/guards-and-providers.md index e5e47a62..a1fa6192 100644 --- a/apps/docs/docs/auth/guards-and-providers.md +++ b/apps/docs/docs/auth/guards-and-providers.md @@ -101,6 +101,20 @@ await auth.guard('admin').user() await auth.guard('api').currentAccessToken() ``` +On a token guard, `login()` and `register()` return personal access token results instead of session results: + +```ts +const { data: token, error } = await auth.guard('api').login({ + email: 'ava@example.com', + password: 'secret-secret', + abilities: ['orders.read'], +}) + +if (!error) { + token.plainTextToken +} +``` + ## Default Guard Named exports use the configured default guard: diff --git a/apps/docs/docs/auth/personal-access-tokens.md b/apps/docs/docs/auth/personal-access-tokens.md index 08af6e33..1fd9ed7e 100644 --- a/apps/docs/docs/auth/personal-access-tokens.md +++ b/apps/docs/docs/auth/personal-access-tokens.md @@ -17,7 +17,133 @@ guards: { Tokens are created in plain text once, hashed at rest, and validated on each incoming request. -## Creating Tokens +## Token Login + +Use the token guard's normal `login()` method when a user submits credentials and the response should be a bearer token +instead of a session cookie: + +```ts +import auth from '@holo-js/auth' + +const { data: token, error } = await auth.guard('api').login({ + email: 'ava@example.com', + password: 'secret-secret', + abilities: ['orders.read'], +}) + +if (error) { + return Response.json({ + ok: false, + message: 'Invalid credentials.', + }, { status: 401 }) +} + +return Response.json({ + ok: true, + token: token.plainTextToken, + tokenId: token.id, + abilities: token.abilities, +}, { + headers: { + 'Cache-Control': 'no-store', + }, +}) +``` + +For token guards, `auth.guard('api').login(...)` verifies the credentials, creates a personal access token for the +authenticated user, and returns the token result. + +## Token Registration + +Use `register()` on a token guard when the registration response should immediately return a bearer token: + +```ts +import auth from '@holo-js/auth' + +const { data: token, error } = await auth.guard('api').register({ + name: 'Ava', + email: 'ava@example.com', + password: 'secret-secret', + passwordConfirmation: 'secret-secret', + abilities: ['orders.read'], +}) + +if (error) { + return Response.json(error, { status: error.status }) +} + +return Response.json({ + ok: true, + token: token.plainTextToken, + tokenId: token.id, + abilities: token.abilities, +}, { + headers: { + 'Cache-Control': 'no-store', + }, +}) +``` + +For session guards, `login()` and `register()` still return session results. For token guards, they return personal +access token results. TypeScript infers the guard driver from `config/auth.ts` through the generated `holo prepare` +types, so `auth.guard('api')` is token-backed when the `api` guard uses `driver: 'token'`. + +## Sending Tokens On Requests + +Send the token on protected API requests with the standard `Authorization` header: + +```ts +await fetch('/api/v1/orders', { + headers: { + Authorization: `Bearer ${token}`, + }, +}) +``` + +Framework adapters read the bearer token from the request and make it available to the selected token guard. Server +routes can then use the normal guard APIs: + +```ts +import auth from '@holo-js/auth' + +export async function GET() { + const currentUser = await auth.guard('api').user() + + if (!currentUser) { + return Response.json({ ok: false, message: 'Unauthenticated.' }, { status: 401 }) + } + + if (!currentUser.can('orders.read')) { + return Response.json({ ok: false, message: 'Forbidden.' }, { status: 403 }) + } + + const token = await auth.guard('api').currentAccessToken() + + return Response.json({ + ok: true, + userId: currentUser.id, + abilities: token?.abilities ?? [], + }) +} +``` + +The ability check should match the abilities you issue during token creation. In the login example above, the token gets +`orders.read`, so routes that expose order data should call `currentUser.can('orders.read')` before returning it. The +current token also exposes `token.can('orders.read')` when the route needs to inspect the token directly. A token with +`*` passes individual ability checks. + +`check()` follows the same guard context: + +```ts +if (!await auth.guard('api').check()) { + return Response.json({ ok: false, message: 'Unauthenticated.' }, { status: 401 }) +} +``` + +## Manual Token Creation + +Use the lower-level `tokens.create(...)` API when the user is already authenticated or trusted and your application +needs to issue a token manually, such as from an account settings screen. ```ts import { tokens } from '@holo-js/auth' @@ -35,12 +161,7 @@ The result contains: Show the plain text token to the user immediately after creation. The unhashed secret should be treated as write-only. -## Sending Tokens On Requests - -The application should pass the token as a bearer token or another transport of its choice, then assign it to the -token guard context before using the auth runtime for that request. - -## Authenticating Tokens +## Manual Token Authentication ```ts import { tokens } from '@holo-js/auth' @@ -50,6 +171,9 @@ const actor = await tokens.authenticate(created.plainTextToken) The runtime validates the token id and secret, updates `lastUsedAt`, and resolves the local user model. +Most framework routes should use `auth.guard('api').user()` or `auth.guard('api').check()` instead. Use +`tokens.authenticate(...)` when you are outside a Holo request context or implementing a custom token transport. + ## Token Abilities ```ts diff --git a/packages/adapter-nuxt/src/module.ts b/packages/adapter-nuxt/src/module.ts index 9dc75687..79d635e5 100644 --- a/packages/adapter-nuxt/src/module.ts +++ b/packages/adapter-nuxt/src/module.ts @@ -346,6 +346,7 @@ export default defineNuxtModule({ const opts = nuxt.options as unknown as NuxtOptionsWithNitro const rootDir = opts.rootDir ?? opts.srcDir ?? process.cwd() const sourceDir = opts.srcDir ?? rootDir + const authTypesPath = resolve(rootDir, '.holo-js/generated/auth.d.ts') const modelRegistryTypesPath = resolve(rootDir, '.holo-js/generated/model-registry.d.ts') addViteOptimizeDeps(opts, resolveClientOptimizeDeps(rootDir)) const loaded = await loadConfigDirectory(rootDir, { @@ -459,9 +460,11 @@ export default defineNuxtModule({ if (!opts._holoTypesRegistered) { opts._holoTypesRegistered = true + await ensureModelRegistryTypesPlaceholder(authTypesPath) await ensureModelRegistryTypesPlaceholder(modelRegistryTypesPath) nuxt.hook('prepare:types', ({ references }) => { references.push({ types: '@holo-js/adapter-nuxt' }) + references.push({ path: authTypesPath }) references.push({ path: modelRegistryTypesPath }) }) } diff --git a/packages/adapter-nuxt/tests/module.test.ts b/packages/adapter-nuxt/tests/module.test.ts index 200cf67b..b90d0e07 100644 --- a/packages/adapter-nuxt/tests/module.test.ts +++ b/packages/adapter-nuxt/tests/module.test.ts @@ -349,7 +349,11 @@ export default defineDatabaseConfig({ const references: Array> = [] prepareTypes?.({ references }) expect(references).toContainEqual({ types: '@holo-js/adapter-nuxt' }) + expect(references).toContainEqual({ path: resolve(root, '.holo-js/generated/auth.d.ts') }) expect(references).toContainEqual({ path: resolve(root, '.holo-js/generated/model-registry.d.ts') }) + expect(await readFile(resolve(root, '.holo-js/generated/auth.d.ts'), 'utf8')).toBe( + '// Generated by holo prepare. Do not edit.\n\nexport {}\n', + ) expect(await readFile(resolve(root, '.holo-js/generated/model-registry.d.ts'), 'utf8')).toBe( '// Generated by holo prepare. Do not edit.\n\nexport {}\n', ) diff --git a/packages/adapter-nuxt/tests/setup.test.ts b/packages/adapter-nuxt/tests/setup.test.ts index f2333e6c..baaa3cfa 100644 --- a/packages/adapter-nuxt/tests/setup.test.ts +++ b/packages/adapter-nuxt/tests/setup.test.ts @@ -517,6 +517,7 @@ export default defineStorageConfig({ prepareTypesHook?.({ references }) expect(references).toEqual([ { types: '@holo-js/adapter-nuxt' }, + { path: join(root, '.holo-js/generated/auth.d.ts') }, { path: join(root, '.holo-js/generated/model-registry.d.ts') }, ]) }, 30000) diff --git a/packages/auth-clerk/src/contracts.ts b/packages/auth-clerk/src/contracts.ts index 50f34613..281397f8 100644 --- a/packages/auth-clerk/src/contracts.ts +++ b/packages/auth-clerk/src/contracts.ts @@ -1,4 +1,4 @@ -import type { AuthEstablishedSession, AuthLogoutResult, AuthUserLike } from '@holo-js/auth' +import type { AuthenticatedAuthUser, AuthEstablishedSession, AuthLogoutResult } from '@holo-js/auth' import type { AuthClerkProviderConfig } from '@holo-js/config' export interface ClerkEmailAddress { @@ -55,7 +55,7 @@ export type ClerkCompleteAuthResult readonly identity: HostedIdentityRecord readonly session: ClerkVerifiedSession readonly authSession?: AuthEstablishedSession @@ -152,12 +152,17 @@ export interface HostedIdentityStore { export type ClerkSyncStatus = 'created' | 'updated' | 'linked' | 'relinked' +export type ClerkAuthenticatedUser> = Readonly>> = + AuthenticatedAuthUser & TUser & { + readonly id: string | number + } + export interface ClerkAuthenticationResult> = Readonly>> { readonly provider: string readonly guard: string readonly authProvider: string readonly status: ClerkSyncStatus - readonly user: AuthUserLike & TUser + readonly user: ClerkAuthenticatedUser readonly identity: HostedIdentityRecord readonly session: ClerkVerifiedSession readonly authSession?: AuthEstablishedSession diff --git a/packages/auth-clerk/src/index.ts b/packages/auth-clerk/src/index.ts index bc1d01a7..39332514 100644 --- a/packages/auth-clerk/src/index.ts +++ b/packages/auth-clerk/src/index.ts @@ -1,5 +1,5 @@ import { authRuntimeInternals, getAuthRuntime } from '@holo-js/auth' -import type { AuthEstablishedSession, AuthUserLike } from '@holo-js/auth' +import type { AuthenticatedAuthUser, AuthEstablishedSession, AuthUserLike } from '@holo-js/auth' import { parseCookieHeader } from '@holo-js/session' import type { AuthClerkProviderConfig, NormalizedAuthClerkProviderConfig } from '@holo-js/config' export { @@ -8,6 +8,7 @@ export { export type { ClerkAuthBindings, ClerkAuthFacade, + ClerkAuthenticatedUser, ClerkAuthenticationResult, ClerkCompleteAuthResult, ClerkEmailAddress, @@ -68,7 +69,7 @@ type CompleteClerkAuthOptions TUserAttributes } -type SerializedClerkAuthUser = AuthUserLike & { +type SerializedClerkAuthUser = AuthenticatedAuthUser & { readonly id: string | number } @@ -693,7 +694,7 @@ function serializeLocalUser, providerName: string, -): AuthUserLike & TUserAttributes { +): SerializedClerkAuthUser & TUserAttributes { const id = adapter.getId(user) const serialized = adapter.serialize ? adapter.serialize(user) @@ -708,8 +709,13 @@ function serializeLocalUser false, + enumerable: false, + configurable: true, + }) - return Object.freeze(result) as AuthUserLike & TUserAttributes + return Object.freeze(result) as SerializedClerkAuthUser & TUserAttributes } function resolvePrimaryEmail(profile: ClerkUserProfile): { email?: string, emailVerified: boolean } { diff --git a/packages/auth-workos/src/contracts.ts b/packages/auth-workos/src/contracts.ts index 5f2f30dc..fee941b3 100644 --- a/packages/auth-workos/src/contracts.ts +++ b/packages/auth-workos/src/contracts.ts @@ -1,4 +1,4 @@ -import type { AuthEstablishedSession, AuthLogoutResult, AuthUserLike } from '@holo-js/auth' +import type { AuthenticatedAuthUser, AuthEstablishedSession, AuthLogoutResult, AuthUserLike } from '@holo-js/auth' import type { NormalizedAuthWorkosProviderConfig } from '@holo-js/config' export type WorkosJsonValue = @@ -38,6 +38,11 @@ export interface WorkosLogoutSession { readonly sessionId: string } +export type WorkosAuthenticatedUser = + AuthenticatedAuthUser & TUser & { + readonly id: string | number + } + export type WorkosCompleteAuthResult = | Readonly<{ readonly ok: true @@ -45,7 +50,7 @@ export type WorkosCompleteAuthResult readonly guard: string readonly authProvider: string readonly status: WorkosSyncStatus - readonly user: TUser + readonly user: WorkosAuthenticatedUser readonly identity: HostedIdentityRecord readonly session: WorkosVerifiedSession readonly authSession?: AuthEstablishedSession @@ -112,7 +117,7 @@ export interface WorkosAuthenticationResult { readonly guard: string readonly authProvider: string readonly status: WorkosSyncStatus - readonly user: AuthUserLike + readonly user: WorkosAuthenticatedUser readonly identity: HostedIdentityRecord readonly session: WorkosVerifiedSession readonly authSession?: AuthEstablishedSession diff --git a/packages/auth-workos/src/index.ts b/packages/auth-workos/src/index.ts index aacb7f61..8282f491 100644 --- a/packages/auth-workos/src/index.ts +++ b/packages/auth-workos/src/index.ts @@ -1,6 +1,6 @@ import { createPublicKey, verify as verifySignature } from 'node:crypto' import { authRuntimeInternals, getAuthRuntime } from '@holo-js/auth' -import type { AuthEstablishedSession, AuthUserLike } from '@holo-js/auth' +import type { AuthenticatedAuthUser, AuthEstablishedSession, AuthUserLike } from '@holo-js/auth' import { parseCookieHeader } from '@holo-js/session' import type { NormalizedAuthWorkosProviderConfig } from '@holo-js/config' export { @@ -12,6 +12,7 @@ export type { HostedIdentityStore, WorkosAuthBindings, WorkosAuthFacade, + WorkosAuthenticatedUser, WorkosAuthenticationResult, WorkosCompleteAuthResult, WorkosIdentityProfile, @@ -103,7 +104,7 @@ type WorkosRequestLike = { type WorkosRequestInput = Request | WorkosRequestLike -type SerializedWorkosAuthUser = AuthUserLike & { +type SerializedWorkosAuthUser = AuthenticatedAuthUser & { readonly id: string | number } @@ -787,7 +788,7 @@ function serializeLocalUser( adapter: RuntimeAuthProviderAdapter, user: Record, providerName: string, -): AuthUserLike { +): SerializedWorkosAuthUser { const id = adapter.getId(user) const serialized = adapter.serialize ? adapter.serialize(user) @@ -802,8 +803,13 @@ function serializeLocalUser( enumerable: false, configurable: true, }) + Object.defineProperty(result, 'can', { + value: () => false, + enumerable: false, + configurable: true, + }) - return Object.freeze(result) + return Object.freeze(result) as SerializedWorkosAuthUser } function resolveDisplayName(profile: WorkosIdentityProfile): string { diff --git a/packages/auth/src/contracts.ts b/packages/auth/src/contracts.ts index 58c0c398..7f6931ec 100644 --- a/packages/auth/src/contracts.ts +++ b/packages/auth/src/contracts.ts @@ -146,8 +146,38 @@ export interface AuthUserLike { readonly [key: string]: unknown } +export interface AuthAuthorizable { + can(ability: string): boolean +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace HoloAuth { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + export interface TypeRegistry {} + } +} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface HoloAuthTypeRegistry {} +export interface HoloAuthTypeRegistry extends HoloAuth.TypeRegistry {} + +type AuthGuardDriverName = 'session' | 'token' + +type RegisteredAuthGuards = HoloAuth.TypeRegistry extends { + readonly guards: infer TGuards +} + ? TGuards + : Readonly> + +type RegisteredAuthGuardDriver = TName extends keyof RegisteredAuthGuards + ? Extract + : AuthGuardDriverName + +type AuthGuardFacadeForDriver = TDriver extends 'token' + ? AuthTokenGuardFacade + : AuthSessionGuardFacade + +export type AuthGuardFacadeFor = AuthGuardFacadeForDriver> export type AuthUser = HoloAuthTypeRegistry extends { readonly user: infer TUser @@ -157,6 +187,8 @@ export type AuthUser = HoloAuthTypeRegistry extends { : AuthUserLike : AuthUserLike +export type AuthenticatedAuthUser = AuthUser & AuthAuthorizable + export type HoloAuthUser = AuthUser export interface AuthCredentials extends Readonly> { @@ -199,9 +231,9 @@ export interface AuthImpersonationOptions extends AuthSessionLoginOptions { export interface AuthImpersonationState { readonly guard: string readonly actorGuard: string - readonly user: AuthUser - readonly actor: AuthUser - readonly originalUser: AuthUser | null + readonly user: AuthenticatedAuthUser + readonly actor: AuthenticatedAuthUser + readonly originalUser: AuthenticatedAuthUser | null readonly startedAt: Date } @@ -210,37 +242,69 @@ export interface AuthLogoutResult { readonly cookies: readonly string[] } -export interface AuthGuardFacade { +export type AuthGuardLoginData = AuthEstablishedSession | PersonalAccessTokenResult +export type AuthGuardRegistrationData = AuthenticatedAuthUser | PersonalAccessTokenResult + +export interface AuthBaseGuardFacade { check(): Promise - user(): Promise - refreshUser(): Promise + user(): Promise + refreshUser(): Promise provider(): Promise id(): Promise currentAccessToken(): Promise login( credentials: TCredentials, - ): Promise>> + ): Promise>> + register( + input: TInput, + ): Promise>> + logout(): Promise +} + +export interface AuthSessionOnlyFacade { loginUsing(user: unknown, options?: AuthSessionLoginOptions): Promise loginUsingId(userId: string | number, options?: AuthSessionLoginOptions): Promise impersonate(user: unknown, options?: AuthImpersonationOptions): Promise impersonateById(userId: string | number, options?: AuthImpersonationOptions): Promise impersonation(): Promise - stopImpersonating(): Promise - logout(): Promise + stopImpersonating(): Promise +} + +export interface AuthGuardFacade extends AuthBaseGuardFacade, AuthSessionOnlyFacade {} + +export interface AuthSessionGuardFacade extends AuthBaseGuardFacade, AuthSessionOnlyFacade { + login( + credentials: TCredentials, + ): Promise>> + register( + input: TInput, + ): Promise>> +} + +export interface AuthTokenGuardFacade extends AuthBaseGuardFacade { + login( + credentials: TCredentials, + ): Promise>> + register( + input: TInput, + ): Promise>> } export interface AuthFacade extends AuthGuardFacade { + login( + credentials: TCredentials, + ): Promise>> register( input: TInput, - ): Promise>> + ): Promise>> requestPasswordReset( input: TInput, options?: AuthPasswordResetRequestOptions, ): Promise>> resetPassword( input: TInput, - ): Promise>> - verifyEmail(token: string): Promise>> + ): Promise>> + verifyEmail(token: string): Promise>> sendEmailVerification(): Promise>> sendEmailVerification(email: string): Promise>> sendEmailVerification(email: string | undefined): Promise>> @@ -252,7 +316,7 @@ export interface AuthFacade extends AuthGuardFacade { hashPassword(password: string): Promise verifyPassword(password: string, digest: string): Promise needsPasswordRehash(digest: string): Promise - guard(name: string): AuthGuardFacade + guard(name: TName): string extends TName ? AuthGuardFacade : AuthGuardFacadeFor tokens: AuthTokenFacade verification: AuthEmailVerificationFacade } @@ -330,7 +394,7 @@ export interface AuthTokenFacade { list(user: unknown, options?: { readonly guard?: string }): Promise revoke(options?: { readonly guard?: string }): Promise revokeAll(user: unknown, options?: { readonly guard?: string }): Promise - authenticate(plainTextToken: string): Promise + authenticate(plainTextToken: string): Promise can(token: string, ability: string): Promise } @@ -339,6 +403,7 @@ export interface AuthCurrentAccessToken extends Omit } @@ -403,7 +468,7 @@ export interface PasswordResetTokenStore { export interface AuthDeliveryHook { sendEmailVerification(input: { readonly provider: string - readonly user: AuthUser + readonly user: AuthenticatedAuthUser readonly email: string readonly token: EmailVerificationTokenResult readonly route: string @@ -420,7 +485,7 @@ export interface AuthDeliveryHook { export interface AuthEmailVerificationFacade { create(user: unknown, options?: { readonly guard?: string, readonly expiresAt?: Date }): Promise resend(options?: { readonly guard?: string, readonly expiresAt?: Date, readonly email?: string }): Promise>> - consume(plainTextToken: string): Promise>> + consume(plainTextToken: string): Promise>> } export interface AuthSessionRecord { @@ -478,8 +543,8 @@ export interface AuthSessionRuntime { export interface AuthRuntimeContext { getSessionId(guardName: string): string | undefined setSessionId(guardName: string, sessionId?: string): void - getCachedUser(guardName: string): AuthUser | null | undefined - setCachedUser(guardName: string, user: AuthUser | null): void + getCachedUser(guardName: string): AuthenticatedAuthUser | null | undefined + setCachedUser(guardName: string, user: AuthenticatedAuthUser | null): void getRequestCookie?(name: string): string | undefined | Promise getRequestHeader?(name: string): string | undefined | Promise appendResponseCookie?(cookie: string): void | Promise @@ -509,7 +574,7 @@ export interface AuthRuntimeFacade extends AuthFacade { export interface AuthEstablishedSession { readonly guard: string readonly provider: string - readonly user: AuthUser + readonly user: AuthenticatedAuthUser readonly sessionId: string readonly rememberToken?: string readonly cookies: readonly string[] @@ -521,7 +586,7 @@ export interface CurrentAuthResponse { readonly authenticated: boolean readonly guard: string readonly provider: string | null - readonly user: AuthUser | null + readonly user: HoloAuthUser | null } export interface AuthClientConfig { diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 11371c58..4c73e5b7 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,4 +1,5 @@ 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' +import type { AuthFacade } from './contracts' export { AUTH_ERROR_CODES, AuthError, defineAuthConfig, isAuthError } from './contracts' export { @@ -35,6 +36,8 @@ export { } from './runtime' export type { AuthFailure, + AuthenticatedAuthUser, + AuthAuthorizable, AuthClientConfig, AuthClientRequestOptions, AuthCredentials, @@ -47,10 +50,16 @@ export type { AuthEmailVerificationSendOptions, AuthEstablishedSession, AuthFacade, + AuthBaseGuardFacade, AuthFieldErrors, AuthFailureResult, AuthGuardFacade, + AuthGuardFacadeFor, + AuthGuardLoginData, + AuthGuardRegistrationData, AuthInputFieldErrors, + AuthSessionGuardFacade, + AuthSessionOnlyFacade, AuthImpersonationOptions, AuthImpersonationState, AuthLoginErrorCode, @@ -66,6 +75,7 @@ export type { AuthResult, AuthSuccessResult, AuthTokenFacade, + AuthTokenGuardFacade, AuthTokenStore, AuthUser, HoloAuthUser, @@ -92,7 +102,7 @@ export type { PasswordResetTokenStore, } from './contracts' -const auth = Object.freeze({ +const auth: AuthFacade = Object.freeze({ check, user, refreshUser, @@ -118,7 +128,7 @@ const auth = Object.freeze({ verification, verifyEmail, verifyPassword, - guard(name: string) { + guard(name: TName) { return getAuthRuntime().guard(name) }, }) diff --git a/packages/auth/src/runtime.ts b/packages/auth/src/runtime.ts index d97761ca..c6cb0682 100644 --- a/packages/auth/src/runtime.ts +++ b/packages/auth/src/runtime.ts @@ -1,6 +1,7 @@ import { createHash, createHmac } from 'node:crypto' import { normalizeAuthConfig } from '@holo-js/config' import type { + AuthenticatedAuthUser, AuthFieldErrors, AuthCredentials, AuthCurrentAccessToken, @@ -12,6 +13,7 @@ import type { AuthEstablishedSession, AuthFacade, AuthGuardFacade, + AuthGuardFacadeFor, AuthImpersonationOptions, AuthImpersonationState, AuthLoginErrorCode, @@ -25,7 +27,9 @@ import type { AuthRegistrationErrorCode, AuthResult, AuthRegistrationInput, + AuthSessionGuardFacade, AuthSessionLoginOptions, + AuthTokenGuardFacade, AuthTokenFacade, AuthTokenStore, AuthUser, @@ -100,7 +104,7 @@ export { createMemoryAuthContext, } from './runtime/context' -type SerializedAuthUser = AuthUser & { +type SerializedAuthUser = AuthenticatedAuthUser & { readonly id: string | number } @@ -582,6 +586,7 @@ function serializeUser( adapter: ErasedAuthProviderAdapter, user: unknown, providerName?: string, + options: { readonly can?: (ability: string) => boolean } = {}, ): SerializedAuthUser { const serialized = adapter.serialize ? adapter.serialize(user) @@ -594,6 +599,11 @@ function serializeUser( ...requireRecordValue(serialized, '[@holo-js/auth] Auth provider serialize() must return an object user.'), id, } + Object.defineProperty(result, 'can', { + value: options.can ?? (() => false), + enumerable: false, + configurable: true, + }) if (providerName) { Object.defineProperty(result, AUTH_PROVIDER_MARKER, { value: providerName, @@ -613,6 +623,11 @@ function rehydrateSerializedUser( ...user, id: user.id, } + Object.defineProperty(restored, 'can', { + value: () => false, + enumerable: false, + configurable: true, + }) Object.defineProperty(restored, AUTH_PROVIDER_MARKER, { value: providerName, enumerable: false, @@ -633,6 +648,19 @@ function getPasswordHash( return typeof value === 'string' ? value : null } +async function verifyCredentialsForProvider( + providerName: string, + adapter: ErasedAuthProviderAdapter, + credentials: AuthCredentials, +): Promise | null> { + const bindings = getRuntimeBindings() + const user = await findUserByConfiguredIdentifiers(adapter, credentials, getProviderIdentifiers(providerName)) + const passwordHash = user ? getPasswordHash(adapter, user) : null + const passwordMatches = await bindings.passwordHasher.verify(credentials.password, passwordHash ?? '') + + return user && passwordMatches ? user : null +} + function isEmailVerificationRequired(): boolean { return getRuntimeBindings().config.emailVerification.required === true } @@ -899,7 +927,7 @@ async function authenticateAccessTokenRecord( plainTextToken: string, ): Promise<{ readonly token: PersonalAccessTokenRecord - readonly user: AuthUser + readonly user: SerializedAuthUser } | null> { const parsed = parsePlainTextToken(plainTextToken) if (!parsed) { @@ -926,7 +954,9 @@ async function authenticateAccessTokenRecord( return { token: updatedRecord, - user: serializeUser(adapter, resolvedUser, tokenRecord.provider), + user: serializeUser(adapter, resolvedUser, tokenRecord.provider, { + can: ability => tokenHasAbility(updatedRecord, ability), + }), } } @@ -934,8 +964,11 @@ function createCurrentAccessTokenHandle( guardName: string, record: PersonalAccessTokenRecord, ): AuthCurrentAccessToken { + const normalizedRecord = normalizeTokenRecord(record) + return Object.freeze({ - ...normalizeTokenRecord(record), + ...normalizedRecord, + can: (ability: string) => tokenHasAbility(normalizedRecord, ability), delete: async () => { await ensureTokenStore().delete(record.id) const bindings = getRuntimeBindings() @@ -977,7 +1010,7 @@ async function resolveCurrentAccessTokenForGuard(guardName: string): Promise { +): Promise { const bindings = getRuntimeBindings() const guard = getGuardConfig(guardName) @@ -1051,19 +1084,13 @@ async function resolveUserFromGuard( return serialized } -async function loginForGuard(guardName: string, credentials: AuthCredentials): Promise { - const bindings = getRuntimeBindings() +async function loginForSessionGuard(guardName: string, credentials: AuthCredentials): Promise { const { guard, adapter } = getGuardProviderAdapter(guardName) - const user = await findUserByConfiguredIdentifiers(adapter, credentials, getProviderIdentifiers(guard.provider)) + const user = await verifyCredentialsForProvider(guard.provider, adapter, credentials) if (!user) { throwAuthError('invalid_credentials', 'Invalid credentials.') } - const passwordHash = getPasswordHash(adapter, user) - if (!passwordHash || !(await bindings.passwordHasher.verify(credentials.password, passwordHash))) { - throwAuthError('invalid_credentials', 'Invalid credentials.') - } - const serialized = serializeUser(adapter, user, guard.provider) await hydrateGuardContextFromRequest(guardName) return establishSessionForUser(serialized, { @@ -1073,6 +1100,22 @@ async function loginForGuard(guardName: string, credentials: AuthCredentials): P }) } +async function loginForGuard(guardName: string, credentials: AuthCredentials): Promise { + const guard = getGuardConfig(guardName) + if (guard.driver === 'session') { + return loginForSessionGuard(guardName, credentials) + } + + const { adapter } = getProviderAdapter(guard.provider) + const user = await verifyCredentialsForProvider(guard.provider, adapter, credentials) + if (!user) { + throwAuthError('invalid_credentials', 'Invalid credentials.') + } + + const serialized = serializeUser(adapter, user, guard.provider) + return createLoginTokenForGuard(guardName, serialized, credentials) +} + function assertTrustedUserProvider( guardName: string, providerName: string, @@ -1155,7 +1198,7 @@ function isCompatibleSerializedUserCandidate( return false } - const serializedRecord = serialized as Readonly> + const serializedRecord = serialized as unknown as Readonly> for (const [key, value] of Object.entries(candidate)) { if (typeof value === 'undefined') { @@ -1449,7 +1492,7 @@ async function impersonationForGuard(guardName: string): Promise { +async function stopImpersonatingForGuard(guardName: string): Promise { const bindings = getRuntimeBindings() const state = await readGuardSessionState(guardName) if (!state || !state.payload.impersonation) { @@ -1532,12 +1575,11 @@ async function logoutForGuard(guardName: string): Promise { }) } -async function registerDefaultUser(input: AuthRegistrationInput): Promise { +async function registerUserForGuard(guardName: string, input: AuthRegistrationInput): Promise { ensurePasswordConfirmation(input) const bindings = getRuntimeBindings() - const defaultGuard = bindings.config.defaults.guard - const guard = getGuardConfig(defaultGuard) + const guard = getGuardConfig(guardName) const { adapter } = getProviderAdapter(guard.provider) const identifiers = getProviderIdentifiers(guard.provider) const lookup = toLookupCredentials(input, identifiers) @@ -1558,7 +1600,7 @@ async function registerDefaultUser(input: AuthRegistrationInput): Promise undefined) @@ -1570,6 +1612,26 @@ async function registerDefaultUser(input: AuthRegistrationInput): Promise { + return registerUserForGuard(getRuntimeBindings().config.defaults.guard, input) +} + +async function registerForGuard(guardName: string, input: AuthRegistrationInput): Promise { + const guard = getGuardConfig(guardName) + if (guard.driver === 'token') { + ensureTokenStore() + const user = await registerUserForGuard(guardName, input) + try { + return await createLoginTokenForGuard(guardName, user, input) + } catch (error) { + await rollbackSerializedUserForGuard(guardName, user).catch(() => undefined) + throw error + } + } + + return registerUserForGuard(guardName, input) +} + async function rollbackRegisteredUser( adapter: ErasedAuthProviderAdapter, createdUser: unknown, @@ -1600,6 +1662,30 @@ async function rollbackRegisteredUser( } } +async function rollbackSerializedUserForGuard(guardName: string, user: SerializedAuthUser): Promise { + const guard = getGuardConfig(guardName) + const { adapter } = getProviderAdapter(guard.provider) + let adapterDeleteError: unknown + if (adapter.delete) { + try { + await adapter.delete(user.id) + return + } catch (error) { + adapterDeleteError = error + } + } + + const createdUser = await adapter.findById(user.id) + if (createdUser && typeof createdUser === 'object' && 'delete' in createdUser && typeof createdUser.delete === 'function') { + await createdUser.delete() + return + } + + if (adapterDeleteError) { + throw adapterDeleteError + } +} + function findProviderNameForUser(user: unknown): string { const bindings = getRuntimeBindings() const providerNames = Object.keys(bindings.providers) @@ -1628,7 +1714,7 @@ function findProviderNameForUser(user: unknown): string { } async function establishSessionForUser( - user: AuthUser, + user: SerializedAuthUser, options: { readonly guard: string readonly provider: string @@ -1659,7 +1745,7 @@ async function establishSessionForUser( && existingPayloads[options.guard] ) const sessionPayload = options.payload - ?? toSessionPayload(options.guard, options.provider, user as SerializedAuthUser) + ?? toSessionPayload(options.guard, options.provider, user) const sessionPayloads = { ...existingPayloads, [options.guard]: sessionPayload, @@ -1725,7 +1811,7 @@ async function establishSessionForUser( sessionId: session.id, rememberToken, cookies: Object.freeze(cookies), - ...(isEmailVerificationRequired() && !hasVerifiedEmail(user as Readonly>) + ...(isEmailVerificationRequired() && !hasVerifiedEmail(user as unknown as Readonly>) ? { emailVerificationRequired: true, emailVerificationRoute: createEmailVerificationRedirectRoute(user), @@ -1751,6 +1837,26 @@ function toPlainTextTokenResult( }) } +function getTokenAbilitiesFromInput(input?: Readonly>): readonly string[] | undefined { + const abilities = input?.abilities + return Array.isArray(abilities) && abilities.every(ability => typeof ability === 'string') + ? abilities + : undefined +} + +function createLoginTokenForGuard( + guardName: string, + user: AuthUser, + input?: Readonly>, +): Promise { + const abilities = getTokenAbilitiesFromInput(input) + return createTokenFacade().create(user, { + guard: guardName, + name: guardName, + ...(abilities ? { abilities } : {}), + }) +} + async function updateUserRecord( providerName: string, userId: string | number, @@ -1761,7 +1867,7 @@ async function updateUserRecord( readonly email_verified_at?: Date | null readonly password?: string | null }, -): Promise { +): Promise { const { adapter } = getProviderAdapter(providerName) const user = await adapter.findById(userId) if (!user) { @@ -1838,7 +1944,7 @@ function createEmailVerificationFacade(): AuthEmailVerificationFacade { resend(options: { readonly guard?: string, readonly expiresAt?: Date, readonly email?: string } = {}): Promise>> { return captureExpectedAuthResult(async () => { const guardName = options.guard ?? getDefaultGuardName() - let currentUser: AuthUser | null + let currentUser: AuthenticatedAuthUser | null if (typeof options.email === 'string' && options.email.trim().length > 0) { const { provider, adapter } = getGuardProviderAdapter(guardName) const matchedUser = await adapter.findByCredentials({ @@ -1857,7 +1963,7 @@ function createEmailVerificationFacade(): AuthEmailVerificationFacade { }) } - if (hasVerifiedEmail(currentUser as Readonly>)) { + if (hasVerifiedEmail(currentUser as unknown as Readonly>)) { throwAuthError('email_already_verified', 'Your email address is already verified.', { guard: guardName, }) @@ -1869,7 +1975,7 @@ function createEmailVerificationFacade(): AuthEmailVerificationFacade { }) }, EXPECTED_EMAIL_VERIFICATION_RESEND_ERRORS, createEmailVerificationResendFailure) }, - consume(plainTextToken: string): Promise>> { + consume(plainTextToken: string): Promise>> { return captureExpectedAuthResult(async () => { const parsed = parsePlainTextToken(plainTextToken) if (!parsed) { @@ -2002,7 +2108,7 @@ async function requestPasswordResetUsingRuntime( input: TInput, -): Promise, readonly string[]>>>> { +): Promise, readonly string[]>>>> { return captureExpectedAuthResult(async () => { if (input.password !== input.passwordConfirmation) { throwAuthError('password_confirmation_mismatch', 'Password confirmation does not match.') @@ -2112,7 +2218,7 @@ function createTokenFacade(): AuthTokenFacade { ) return tokenStore.deleteByUserId(providerName, userId) }, - async authenticate(plainTextToken: string): Promise { + async authenticate(plainTextToken: string): Promise { const authenticated = await authenticateAccessTokenRecord(plainTextToken) return authenticated?.user ?? null }, @@ -2123,8 +2229,8 @@ function createTokenFacade(): AuthTokenFacade { }) } -function createGuardFacade(guardName: string): AuthGuardFacade { - return Object.freeze({ +function createGuardFacade(guardName: string): AuthSessionGuardFacade | AuthTokenGuardFacade { + const base = { check() { return checkForGuard(guardName) }, @@ -2150,6 +2256,24 @@ function createGuardFacade(guardName: string): AuthGuardFacade { error => createLoginFailure(error, credentials), ) }, + register(input: TInput) { + return captureExpectedAuthResult( + () => registerForGuard(guardName, input), + EXPECTED_REGISTRATION_ERRORS, + error => createRegistrationFailure(error, input), + ) + }, + logout() { + return logoutForGuard(guardName) + }, + } + + if (getGuardConfig(guardName).driver === 'token') { + return Object.freeze(base) as AuthTokenGuardFacade + } + + return Object.freeze({ + ...base, loginUsing(user: unknown, options?: AuthSessionLoginOptions) { return loginUsingForGuard(guardName, user, options) }, @@ -2168,10 +2292,7 @@ function createGuardFacade(guardName: string): AuthGuardFacade { stopImpersonating() { return stopImpersonatingForGuard(guardName) }, - logout() { - return logoutForGuard(guardName) - }, - }) + }) as AuthSessionGuardFacade } export function configureAuthRuntime(bindings?: AuthRuntimeBindings): void { @@ -2180,8 +2301,17 @@ export function configureAuthRuntime(bindings?: AuthRuntimeBindings): void { return } + const config = normalizeAuthConfig(bindings.config) + const defaultGuard = config.guards[config.defaults.guard] + if (defaultGuard?.driver === 'token') { + throw new Error( + `[@holo-js/auth] The default auth guard "${config.defaults.guard}" uses the token driver. ` + + 'Use a session-backed default guard for top-level login/register, or call auth.guard(name).login/register for token guards.', + ) + } + getAuthRuntimeState().bindings = { - config: normalizeAuthConfig(bindings.config), + config, session: bindings.session, providers: bindings.providers, tokens: bindings.tokens, @@ -2219,7 +2349,7 @@ export function getAuthRuntime(): AuthRuntimeFacade { }, login(credentials: TCredentials) { return captureExpectedAuthResult( - () => loginForGuard(getDefaultGuardName(), credentials), + () => loginForSessionGuard(getDefaultGuardName(), credentials), EXPECTED_LOGIN_ERRORS, error => createLoginFailure(error, credentials), ) @@ -2276,8 +2406,8 @@ export function getAuthRuntime(): AuthRuntimeFacade { needsPasswordRehash(digest: string) { return resolveNeedsPasswordRehash(getRuntimeBindings().passwordHasher, digest) }, - guard(name: string) { - return createGuardFacade(name) + guard(name: TName): string extends TName ? AuthGuardFacade : AuthGuardFacadeFor { + return createGuardFacade(name) as string extends TName ? AuthGuardFacade : AuthGuardFacadeFor }, tokens, verification, @@ -2313,11 +2443,11 @@ export async function checkForGuard(guardName: string): Promise { return (await userForGuard(guardName)) !== null } -export async function userForGuard(guardName: string): Promise { +export async function userForGuard(guardName: string): Promise { return resolveUserFromGuard(guardName) } -export async function refreshUserForGuard(guardName: string): Promise { +export async function refreshUserForGuard(guardName: string): Promise { return resolveUserFromGuard(guardName, { fresh: true }) } @@ -2347,11 +2477,11 @@ export async function check(): Promise { return getAuthRuntime().check() } -export async function user(): Promise { +export async function user(): Promise { return getAuthRuntime().user() } -export async function refreshUser(): Promise { +export async function refreshUser(): Promise { return getAuthRuntime().refreshUser() } @@ -2405,7 +2535,7 @@ export async function impersonation(): Promise { return getAuthRuntime().impersonation() } -export async function stopImpersonating(): Promise { +export async function stopImpersonating(): Promise { return getAuthRuntime().stopImpersonating() } @@ -2436,7 +2566,7 @@ export async function logout(): Promise { export async function register( input: TInput, -): Promise, readonly string[]>>>> { +): Promise, readonly string[]>>>> { return getAuthRuntime().register(input) } @@ -2449,13 +2579,13 @@ export async function requestPasswordReset( input: TInput, -): Promise, readonly string[]>>>> { +): Promise, readonly string[]>>>> { return getAuthRuntime().resetPassword(input) } export function verifyEmail( token: string, -): Promise>> { +): Promise>> { return getAuthRuntime().verifyEmail(token) } diff --git a/packages/auth/src/runtime/context.ts b/packages/auth/src/runtime/context.ts index a12c111e..c0fb1a7e 100644 --- a/packages/auth/src/runtime/context.ts +++ b/packages/auth/src/runtime/context.ts @@ -1,9 +1,9 @@ import { AsyncLocalStorage } from 'node:async_hooks' -import type { AuthRuntimeContext, AuthUser } from '../contracts' +import type { AuthenticatedAuthUser, AuthRuntimeContext } from '../contracts' export type MemoryAuthContext = AuthRuntimeContext & { readonly sessionIds: Map - readonly cachedUsers: Map + readonly cachedUsers: Map readonly accessTokens: Map readonly rememberTokens: Map getAccessToken(guardName: string): string | undefined @@ -27,7 +27,7 @@ function setMapValue(map: Map, key: string, value?: string): voi export function createMemoryAuthContext(): MemoryAuthContext { const sessionIds = new Map() - const cachedUsers = new Map() + const cachedUsers = new Map() const accessTokens = new Map() const rememberTokens = new Map() diff --git a/packages/auth/tests/contracts.type.test.ts b/packages/auth/tests/contracts.type.test.ts index 70407958..100a567c 100644 --- a/packages/auth/tests/contracts.type.test.ts +++ b/packages/auth/tests/contracts.type.test.ts @@ -1,5 +1,5 @@ import { describe, expectTypeOf, it } from 'vitest' -import auth, { AuthError, isAuthError, type 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 auth, { AuthError, isAuthError, type AuthenticatedAuthUser, 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 PersonalAccessTokenResult, type register, type user, type verifyEmail } from '../src' 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' @@ -17,6 +17,19 @@ declare module '../src' { } } +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace HoloAuth { + export interface TypeRegistry { + guards: { + readonly web: 'session' + readonly admin: 'session' + readonly api: 'token' + } + } + } +} + describe('@holo-js/auth typing', () => { it('preserves the augmented auth user shape across server and client helpers', () => { type AppAuthUser = { @@ -26,6 +39,9 @@ describe('@holo-js/auth typing', () => { readonly role: 'admin' | 'member' readonly avatarUrl?: string | null } + type AppAuthenticatedUser = AppAuthUser & { + can(ability: string): boolean + } type RegisteredUser = Awaited> type CurrentServerUser = Awaited> @@ -39,6 +55,15 @@ describe('@holo-js/auth typing', () => { type GuardProvider = Awaited> type GuardUser = Awaited> type GuardRefreshedUser = Awaited> + type WebGuardLogin = Awaited>['login']>> + type ApiGuardLogin = Awaited>['login']>> + type ApiGuardRegister = Awaited>['register']>> + type ApiGuardHasLoginUsing = 'loginUsing' extends keyof ReturnType> ? true : false + type ApiGuardHasImpersonate = 'impersonate' extends keyof ReturnType> ? true : false + type DynamicGuard = ReturnType> + type DynamicGuardTrustedSession = Awaited> + type GuardLogin = Awaited> + type GuardRegister = Awaited> type TrustedSession = Awaited> type TrustedIdSession = Awaited> type ImpersonatedSession = Awaited> @@ -48,9 +73,10 @@ describe('@holo-js/auth typing', () => { type RuntimeLogoutAll = Awaited['logoutAll']>> expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf>() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf boolean @@ -65,13 +91,21 @@ describe('@holo-js/auth typing', () => { expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf>() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() @@ -115,7 +149,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.user).returns.toEqualTypeOf>() expectTypeOf(auth.provider).returns.toEqualTypeOf>() expectTypeOf(auth.login).returns.toEqualTypeOf>>() expectTypeOf(auth.loginUsing).returns.toEqualTypeOf>() @@ -125,10 +159,10 @@ describe('@holo-js/auth typing', () => { expectTypeOf(auth.needsPasswordRehash).returns.toEqualTypeOf>() expectTypeOf(auth.impersonate).returns.toEqualTypeOf>() expectTypeOf(auth.impersonation).returns.toEqualTypeOf>() - expectTypeOf(auth.stopImpersonating).returns.toEqualTypeOf>() + expectTypeOf(auth.stopImpersonating).returns.toEqualTypeOf>() expectTypeOf(auth.logout).returns.toEqualTypeOf>() expectTypeOf(auth.verifyEmail).parameter(0).toEqualTypeOf() - expectTypeOf(auth.verifyEmail).returns.toEqualTypeOf>>>() + expectTypeOf(auth.verifyEmail).returns.toEqualTypeOf>>>() expectTypeOf(auth.sendEmailVerification).parameter(0).toEqualTypeOf() expectTypeOf(auth.sendEmailVerification).returns.toEqualTypeOf>>>() expectTypeOf(auth.resendEmailVerification).parameter(0).toEqualTypeOf() @@ -160,14 +194,14 @@ describe('@holo-js/auth typing', () => { }> >() expectTypeOf>>().toEqualTypeOf< - AuthResult >() expectTypeOf>>().toEqualTypeOf< - AuthResult> + AuthResult> >() }) diff --git a/packages/auth/tests/docs-smoke.test.ts b/packages/auth/tests/docs-smoke.test.ts index 528a6ac3..b5a26ee6 100644 --- a/packages/auth/tests/docs-smoke.test.ts +++ b/packages/auth/tests/docs-smoke.test.ts @@ -50,6 +50,15 @@ describe('auth documentation smoke checks', () => { expect(guards).toContain('web') expect(guards).toContain('admin') expect(guards).toContain('identifiers') + expect(tokens).toContain('auth.guard(\'api\').login') + expect(tokens).toContain('auth.guard(\'api\').register') + expect(tokens).toContain('config/auth.ts') + expect(tokens).toContain('holo prepare') + expect(tokens).toContain('abilities: [\'orders.read\']') + expect(tokens).toContain('currentUser.can(\'orders.read\')') + expect(tokens).toContain('token.can(\'orders.read\')') + expect(tokens).toContain('Forbidden.') + expect(tokens).toContain('Cache-Control') expect(tokens).toContain('tokens.create') expect(tokens).toContain('tokens.authenticate') expect(tokens).toContain('tokens.can') diff --git a/packages/auth/tests/package.test.ts b/packages/auth/tests/package.test.ts index f8fba872..2fff7c48 100644 --- a/packages/auth/tests/package.test.ts +++ b/packages/auth/tests/package.test.ts @@ -62,6 +62,7 @@ function hashPasswordResetEmail(email: string, csrfSigningKey?: string): string return createHash('sha256').update(canonicalEmail).digest('hex') } import type { + AuthenticatedAuthUser, AuthErrorCode, AuthDeliveryHook, AuthProviderAdapter, @@ -76,6 +77,19 @@ import type { AuthTokenStore, } from '../src' +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace HoloAuth { + export interface TypeRegistry { + guards: { + readonly web: 'session' + readonly admin: 'session' + readonly api: 'token' + } + } + } +} + function unwrapAuthResult(result: AuthResult): TData { if (result.error) { throw new Error(`Expected auth success but received ${result.error.code}.`) @@ -1639,6 +1653,28 @@ describe('@holo-js/auth package runtime', () => { }) }) + it('invokes password verification when credential lookup misses a user', async () => { + const verify = vi.fn(async () => false) + configureRuntime({ + passwordHasher: { + hash: vi.fn(async () => 'real-hash'), + verify, + }, + }) + + await expect(auth.login({ + email: 'missing@example.com', + password: 'secret-secret', + })).resolves.toMatchObject({ + data: null, + error: { + code: 'invalid_credentials', + }, + }) + + expect(verify).toHaveBeenCalledWith('secret-secret', '') + }) + it('creates, expires, rejects, and consumes email verification tokens', async () => { const runtime = configureRuntime() const created = unwrapAuthResult(await register({ @@ -3912,6 +3948,143 @@ describe('@holo-js/auth package runtime', () => { await expect(tokens.can(created.plainTextToken, 'orders.write')).resolves.toBe(false) }) + it('returns a personal access token when logging into a token guard with credentials', async () => { + const runtime = configureRuntime() + const password = await authRuntimeInternals.createDefaultPasswordHasher().hash('secret-secret') + const userRecord = await runtime.usersProvider.create({ + name: 'Ava', + email: 'ava@example.com', + password, + email_verified_at: new Date('2026-04-08T00:00:00.000Z'), + }) + + const token = unwrapAuthResult(await auth.guard('api').login({ + email: 'ava@example.com', + password: 'secret-secret', + abilities: ['posts.read'], + })) + + expect('plainTextToken' in token).toBe(true) + if (!('plainTextToken' in token)) { + throw new Error('Expected token guard login to return a personal access token.') + } + + expect(token.name).toBe('api') + expect(token.provider).toBe('users') + expect(token.userId).toBe(userRecord.id) + expect(token.abilities).toEqual(['posts.read']) + await expect(tokens.authenticate(token.plainTextToken)).resolves.toMatchObject({ + id: userRecord.id, + email: 'ava@example.com', + }) + expect(runtime.context.getSessionId('api')).toBeUndefined() + }) + + it('registers users through a token guard and returns a personal access token', async () => { + const runtime = configureRuntime() + + const token = unwrapAuthResult(await auth.guard('api').register({ + name: 'Mina', + email: 'mina@example.com', + password: 'secret-secret', + passwordConfirmation: 'secret-secret', + abilities: ['posts.read'], + })) + + expect('plainTextToken' in token).toBe(true) + if (!('plainTextToken' in token)) { + throw new Error('Expected token guard registration to return a personal access token.') + } + + expect(token.name).toBe('api') + expect(token.provider).toBe('users') + expect(token.abilities).toEqual(['posts.read']) + await expect(tokens.authenticate(token.plainTextToken)).resolves.toMatchObject({ + id: token.userId, + email: 'mina@example.com', + }) + expect(await runtime.usersProvider.findByCredentials({ email: 'mina@example.com' })).toMatchObject({ + name: 'Mina', + }) + expect(runtime.context.getSessionId('api')).toBeUndefined() + }) + + it('rolls back token guard registration when token creation fails', async () => { + const runtime = configureRuntime() + vi.spyOn(runtime.tokenStore, 'create').mockRejectedValueOnce(new Error('Token persistence failed.')) + + await expect(auth.guard('api').register({ + name: 'Rollback User', + email: 'rollback@example.com', + password: 'secret-secret', + passwordConfirmation: 'secret-secret', + })).rejects.toThrow('Token persistence failed.') + + await expect(runtime.usersProvider.findByCredentials({ + email: 'rollback@example.com', + })).resolves.toBeNull() + }) + + it('rolls back token guard registration with the created model when adapter deletion is unavailable', async () => { + const runtime = configureRuntime() + const originalCreate = runtime.usersProvider.create.bind(runtime.usersProvider) + const originalDelete = runtime.usersProvider.delete.bind(runtime.usersProvider) + vi.spyOn(runtime.tokenStore, 'create').mockRejectedValueOnce(new Error('Token persistence failed.')) + Object.defineProperty(runtime.usersProvider, 'delete', { + value: undefined, + configurable: true, + }) + runtime.usersProvider.create = vi.fn(async (input) => { + const created = await originalCreate(input) + + return Object.assign(created, { + async delete() { + await originalDelete(created.id) + }, + }) + }) + + await expect(auth.guard('api').register({ + name: 'Model Rollback User', + email: 'model-rollback@example.com', + password: 'secret-secret', + passwordConfirmation: 'secret-secret', + })).rejects.toThrow('Token persistence failed.') + + await expect(runtime.usersProvider.findByCredentials({ + email: 'model-rollback@example.com', + })).resolves.toBeNull() + }) + + it('rejects token guards as the default top-level auth guard', () => { + const runtime = configureRuntime() + + expect(() => configureAuthRuntime({ + config: defineAuthConfig({ + defaults: { + guard: 'api', + }, + guards: { + api: { + driver: 'token', + provider: 'users', + }, + }, + providers: { + users: { + model: 'User', + }, + }, + }), + session: getSessionRuntime(), + providers: { + users: runtime.usersProvider, + }, + tokens: runtime.tokenStore, + context: runtime.context, + })).toThrow('default auth guard "api" uses the token driver') + }) + it('uses configured default token abilities when none are provided explicitly', async () => { const runtime = configureRuntime({ authConfig: { @@ -4035,6 +4208,12 @@ describe('@holo-js/auth package runtime', () => { name: 'mobile-app', abilities: ['orders.read'], }) + expect(current?.can('orders.read')).toBe(true) + expect(current?.can('orders.write')).toBe(false) + + const currentUser = await auth.guard('api').user() + expect(currentUser?.can('orders.read')).toBe(true) + expect(currentUser?.can('orders.write')).toBe(false) await current?.delete() expect(runtime.context.getAccessToken('api')).toBeUndefined() @@ -4277,7 +4456,7 @@ describe('@holo-js/auth package runtime', () => { await expect(check()).rejects.toThrow('Auth runtime is not configured yet') const runtime = configureRuntime() - await expect(auth.guard('missing').check()).rejects.toThrow('Auth guard "missing" is not configured') + expect(() => auth.guard('missing')).toThrow('Auth guard "missing" is not configured') configureAuthRuntime({ config: defineAuthConfig({ @@ -4553,12 +4732,18 @@ describe('@holo-js/auth package runtime', () => { expect(() => context.getSessionId('web')).toThrow('Async auth context is not active') context.activate() context.setSessionId('web', 'session-1') - context.setCachedUser('web', { + const cachedUser: AuthenticatedAuthUser = { id: 1, email: 'ava@example.com', name: 'Ava', role: 'member', + can: () => false, + } + Object.defineProperty(cachedUser, 'can', { + value: cachedUser.can, + enumerable: false, }) + context.setCachedUser('web', cachedUser) context.setAccessToken?.('api', 'token-value') context.setRememberToken?.('web', 'remember-value') context.activate() @@ -4569,6 +4754,7 @@ describe('@holo-js/auth package runtime', () => { name: 'Ava', role: 'member', }) + expect(context.getCachedUser('web')?.can('posts.read')).toBe(false) expect(context.getAccessToken?.('api')).toBe('token-value') expect(context.getRememberToken?.('web')).toBe('remember-value') }) @@ -4698,7 +4884,7 @@ describe('@holo-js/auth package runtime', () => { guard: 'web', })).rejects.toThrow('Email verification token runtime is not configured yet') await expect(requestPasswordReset({ email: 'ava@example.com' })).rejects.toThrow('Password reset token runtime is not configured yet') - await expect(auth.guard('api').loginUsing(created)).rejects.toThrow('does not support session login') + expect('loginUsing' in auth.guard('api')).toBe(false) configureAuthRuntime({ config: defineAuthConfig({ @@ -5143,6 +5329,7 @@ describe('@holo-js/auth package runtime', () => { email: 'ava@example.com', name: 'Ava', role: 'member', + can: () => false, }, { guard: 'web', provider: 'users', @@ -5364,6 +5551,7 @@ describe('@holo-js/auth package runtime', () => { email: 'ava@example.com', name: 'Ava', role: 'member', + can: () => false, }, { guard: 'web', provider: 'users', diff --git a/packages/cli/src/project/registry.ts b/packages/cli/src/project/registry.ts index 09a58de4..b69679e4 100644 --- a/packages/cli/src/project/registry.ts +++ b/packages/cli/src/project/registry.ts @@ -9,6 +9,7 @@ import { syncManagedFrameworkArtifacts } from './registry-svelte' import { GENERATED_AUTHORIZATION_REGISTRY_PATH, GENERATED_AUTHORIZATION_TYPES_PATH, + GENERATED_AUTH_TYPES_PATH, CONFIG_EXTENSION_PRIORITY, GENERATED_COMMANDS_PATH, GENERATED_BROADCAST_PATH, @@ -113,6 +114,7 @@ export function renderGeneratedIndexModule(): string { '/* eslint-disable @typescript-eslint/triple-slash-reference */', '', '/// ', + '/// ', '/// ', '/// ', '/// ', @@ -477,6 +479,47 @@ export function renderGeneratedQueueTypes( ].join('\n') } +export function renderGeneratedAuthTypes( + guards: Readonly>, +): string { + const members = Object.entries(guards) + .sort(([left], [right]) => left.localeCompare(right)) + .filter((entry): entry is [string, { readonly driver: 'session' | 'token' }] => { + return entry[1].driver === 'session' || entry[1].driver === 'token' + }) + .map(([name, guard]) => { + return ` readonly ${JSON.stringify(name)}: ${JSON.stringify(guard.driver)}` + }) + + const guardsMember = members.length > 0 + ? [ + ' guards: {', + ...members, + ' }', + ] + : [ + ' guards: Readonly>', + ] + + return [ + '// Generated by holo prepare. Do not edit.', + '', + 'import \'@holo-js/auth\'', + '', + 'declare global {', + ' // eslint-disable-next-line @typescript-eslint/no-namespace', + ' namespace HoloAuth {', + ' interface TypeRegistry {', + ...guardsMember, + ' }', + ' }', + '}', + '', + 'export {}', + '', + ].join('\n') +} + export function renderGeneratedAuthorizationRegistry( registry: Pick, ): string { @@ -780,6 +823,7 @@ export async function writeGeneratedProjectRegistry( await writeFileIfChanged(resolve(projectRoot, GENERATED_CHANNELS_PATH), renderGeneratedModule('channels', nextRegistry.channels)) await writeFileIfChanged(resolve(projectRoot, GENERATED_BROADCAST_MANIFEST_PATH), renderGeneratedBroadcastManifest(nextRegistry)) await writeFileIfChanged(resolve(projectRoot, GENERATED_BROADCAST_TYPES_PATH), renderGeneratedBroadcastTypes(nextRegistry.broadcast, nextRegistry.channels)) + await writeFileIfChanged(resolve(projectRoot, GENERATED_AUTH_TYPES_PATH), renderGeneratedAuthTypes(loadedConfig?.auth?.guards ?? {})) await writeFileIfChanged(resolve(projectRoot, GENERATED_CONFIG_TYPES_PATH), renderGeneratedConfigTypes(projectRoot, configEntries)) await writeFileIfChanged(resolve(projectRoot, GENERATED_EVENT_TYPES_PATH), renderGeneratedEventTypes(nextRegistry.events, nextRegistry.listeners)) await writeFileIfChanged(resolve(projectRoot, GENERATED_QUEUE_TYPES_PATH), renderGeneratedQueueTypes(nextRegistry.jobs)) diff --git a/packages/cli/src/project/shared.ts b/packages/cli/src/project/shared.ts index 62435855..bc0b964d 100644 --- a/packages/cli/src/project/shared.ts +++ b/packages/cli/src/project/shared.ts @@ -444,6 +444,7 @@ export const GENERATED_BROADCAST_MANIFEST_PATH = join(GENERATED_ROOT, 'broadcast export const GENERATED_AUTHORIZATION_ROOT = join(GENERATED_ROOT, 'authorization') export const GENERATED_AUTHORIZATION_REGISTRY_PATH = join(GENERATED_AUTHORIZATION_ROOT, 'registry.ts') export const GENERATED_AUTHORIZATION_TYPES_PATH = join(GENERATED_AUTHORIZATION_ROOT, 'types.d.ts') +export const GENERATED_AUTH_TYPES_PATH = join(GENERATED_ROOT, 'auth.d.ts') export const GENERATED_CONFIG_TYPES_PATH = join(GENERATED_ROOT, 'config.d.ts') export const GENERATED_QUEUE_TYPES_PATH = join(GENERATED_ROOT, 'queue.d.ts') export const GENERATED_EVENT_TYPES_PATH = join(GENERATED_ROOT, 'events.d.ts') diff --git a/packages/cli/tests/authorization-registry.test.ts b/packages/cli/tests/authorization-registry.test.ts index da478b9f..9ff4ff6f 100644 --- a/packages/cli/tests/authorization-registry.test.ts +++ b/packages/cli/tests/authorization-registry.test.ts @@ -6,7 +6,7 @@ import * as configModule from '@holo-js/config' import { normalizeHoloProjectConfig, renderGeneratedSchemaPlaceholder } from '@holo-js/db' import { authorizationInternals } from '../../authorization/src/index' import { prepareProjectDiscovery } from '../src/project/discovery' -import { loadGeneratedProjectRegistry, renderGeneratedAuthorizationRegistry, renderGeneratedAuthorizationTypes, writeGeneratedProjectRegistry } from '../src/project/registry' +import { loadGeneratedProjectRegistry, renderGeneratedAuthTypes, renderGeneratedAuthorizationRegistry, renderGeneratedAuthorizationTypes, writeGeneratedProjectRegistry } from '../src/project/registry' import type { GeneratedAuthorizationAbilityRegistryEntry, GeneratedAuthorizationPolicyRegistryEntry } from '../src/project/shared' const tempDirs: string[] = [] @@ -169,6 +169,43 @@ describe('@holo-js/cli authorization registry discovery', () => { expect(types).toContain('user: import(\'@holo-js/auth\').AuthUser') }) + it('emits auth guard registry types from auth config', async () => { + const root = await createProject() + await writeFile(join(root, 'config/auth.ts'), ` +import { defineAuthConfig } from '@holo-js/config' + +export default defineAuthConfig({ + defaults: { + guard: 'web', + }, + guards: { + web: { + driver: 'session', + provider: 'users', + }, + api: { + driver: 'token', + provider: 'users', + }, + }, +}) +`, 'utf8') + + await prepareProjectDiscovery(root, normalizeHoloProjectConfig()) + + const types = await readFile(join(root, '.holo-js/generated/auth.d.ts'), 'utf8') + const index = await readFile(join(root, '.holo-js/generated/index.ts'), 'utf8') + expect(types).toContain('declare global') + expect(types).toContain('namespace HoloAuth') + expect(types).toContain('readonly "web": "session"') + expect(types).toContain('readonly "api": "token"') + expect(index).toContain('/// ') + }) + + it('renders fallback auth guard types when no guard metadata exists', () => { + expect(renderGeneratedAuthTypes({})).toContain('guards: Readonly>') + }) + it('renders fallback authorization registry types for JavaScript entries without import metadata', () => { const output = renderGeneratedAuthorizationTypes([ { diff --git a/packages/core/src/portable/holo.ts b/packages/core/src/portable/holo.ts index 692cd8d5..83a1e551 100644 --- a/packages/core/src/portable/holo.ts +++ b/packages/core/src/portable/holo.ts @@ -90,6 +90,24 @@ async function preloadGeneratedSchemaModule( } } +async function preloadDiscoveredModelModules( + projectRoot: string, + registry: GeneratedProjectRegistry | undefined, +): Promise { + if (!registry || registry.models.length === 0) { + return + } + + for (const entry of registry.models) { + const sourcePath = resolve(projectRoot, entry.sourcePath) + if (!existsSync(sourcePath)) { + continue + } + + await importBundledRuntimeModule(projectRoot, sourcePath) + } +} + interface CoreNotificationRecord { readonly id: string readonly type?: string @@ -4264,6 +4282,7 @@ export async function createHolo( configureConfigRuntime(loadedConfig.all) configureDB(manager) await preloadGeneratedSchemaModule(projectRoot, registry) + await preloadDiscoveredModelModules(projectRoot, registry) previousOptionalSubsystemBindings = snapshotOptionalSubsystemRuntimeBindings() if (options.renderView) { configureHoloRenderingRuntime({ diff --git a/packages/core/tests/runtime.test.ts b/packages/core/tests/runtime.test.ts index ea44bd55..d25c07c9 100644 --- a/packages/core/tests/runtime.test.ts +++ b/packages/core/tests/runtime.test.ts @@ -344,6 +344,7 @@ export default { async function writeRegistry( root: string, options: { + readonly models?: readonly Record[] readonly jobs?: readonly Record[] readonly events?: readonly Record[] readonly listeners?: readonly Record[] @@ -364,7 +365,7 @@ async function writeRegistry( listeners: 'server/listeners', generatedSchema: 'server/db/schema.ts', }, - models: [ + models: options.models ?? [ { sourcePath: 'server/models/User.ts', name: 'User', @@ -1688,6 +1689,65 @@ export default defineQueueConfig({ expect(reused.registry?.commands[0]?.name).toBe('inspire') }) + it('preloads discovered model modules during runtime initialization', async () => { + const root = await createProject() + await writeBaseConfig(root) + await writeFile(join(root, 'server/models/User.ts'), ` +const state = globalThis.__holoCoreRuntimeModelPreload ??= { + loaded: [], +} + +state.loaded.push('User') + +export default { + definition: { + name: 'User', + }, +} +`, 'utf8') + await writeFile(join(root, 'server/models/Post.ts'), ` +const state = globalThis.__holoCoreRuntimeModelPreload ??= { + loaded: [], +} + +state.loaded.push('Post') + +export default { + definition: { + name: 'Post', + }, +} +`, 'utf8') + await writeRegistry(root, { + models: [ + { + sourcePath: 'server/models/User.ts', + name: 'User', + prunable: false, + }, + { + sourcePath: 'server/models/Post.ts', + name: 'Post', + prunable: false, + }, + ], + }) + + await initializeHolo(root) + + const state = globalThis as typeof globalThis & { + __holoCoreRuntimeModelPreload?: { + loaded: string[] + } + } + + try { + expect(state.__holoCoreRuntimeModelPreload?.loaded).toEqual(['User', 'Post']) + } finally { + delete state.__holoCoreRuntimeModelPreload + } + }) + it('reconfigures queue runtime when framework adapters reuse the current singleton runtime', async () => { const root = await createProject() await writeBaseConfig(root) diff --git a/packages/db/src/model/ModelRegistry.ts b/packages/db/src/model/ModelRegistry.ts index bf6aa1bc..0dcb4874 100644 --- a/packages/db/src/model/ModelRegistry.ts +++ b/packages/db/src/model/ModelRegistry.ts @@ -40,7 +40,14 @@ function definitionsReferToSameModel(left: AnyModelDefinition, right: AnyModelDe && left.morphClass === right.morphClass } -const globalModels = new Map() +function getGlobalModels(): Map { + const runtime = globalThis as typeof globalThis & { + __holoDbGlobalModels__?: Map + } + + runtime.__holoDbGlobalModels__ ??= new Map() + return runtime.__holoDbGlobalModels__ +} export class ModelRegistry { private readonly models = new Map() @@ -79,6 +86,7 @@ export function createModelRegistry(): ModelRegistry { export function registerGlobalModel(reference: ModelDefinitionLike): ModelDefinitionLike { const definition = resolveDefinition(reference) + const globalModels = getGlobalModels() const existing = globalModels.get(definition.name) if (existing) { const existingDefinition = resolveDefinition(existing) @@ -94,9 +102,9 @@ export function registerGlobalModel(reference: ModelDefinitionLike): ModelDefini } export function getGlobalModel(name: string): ModelDefinitionLike | undefined { - return globalModels.get(name) + return getGlobalModels().get(name) } export function resetGlobalModelRegistry(): void { - globalModels.clear() + getGlobalModels().clear() } diff --git a/packages/db/src/model/morphRegistry.ts b/packages/db/src/model/morphRegistry.ts index 1b713858..d56a3fb8 100644 --- a/packages/db/src/model/morphRegistry.ts +++ b/packages/db/src/model/morphRegistry.ts @@ -1,16 +1,24 @@ import type { ModelDefinitionLike } from './types' -const morphRegistry = new Map() +function getMorphRegistry(): Map { + const runtime = globalThis as typeof globalThis & { + __holoDbMorphRegistry__?: Map + } + + runtime.__holoDbMorphRegistry__ ??= new Map() + return runtime.__holoDbMorphRegistry__ +} export function registerMorphModel(type: string, reference: ModelDefinitionLike): void { - morphRegistry.set(type, reference) + getMorphRegistry().set(type, reference) } export function resolveMorphModel(type: string): ModelDefinitionLike | undefined { - return morphRegistry.get(type) + return getMorphRegistry().get(type) } export function resolveMorphSelector(label: string): ModelDefinitionLike | undefined { + const morphRegistry = getMorphRegistry() const exact = morphRegistry.get(label) if (exact) { return exact @@ -34,9 +42,9 @@ export function resolveMorphSelector(label: string): ModelDefinitionLike | undef } export function listMorphModels(): readonly ModelDefinitionLike[] { - return [...morphRegistry.values()] + return [...getMorphRegistry().values()] } export function resetMorphRegistry(): void { - morphRegistry.clear() + getMorphRegistry().clear() } diff --git a/packages/db/tests/model-core.test.ts b/packages/db/tests/model-core.test.ts index f5799d2d..27f89b7c 100644 --- a/packages/db/tests/model-core.test.ts +++ b/packages/db/tests/model-core.test.ts @@ -12,6 +12,7 @@ import { column, configureDB, createModelCollection, + createModelRegistry, createConnectionManager, createDatabase, clearGeneratedTables, @@ -33,7 +34,7 @@ import { type RelationMap, type TableDefinition } from '../src' import { getGlobalModel } from '../src/model/ModelRegistry' -import { registerMorphModel, resolveMorphSelector } from '../src/model/morphRegistry' +import { listMorphModels, registerMorphModel, resolveMorphModel, resolveMorphSelector } from '../src/model/morphRegistry' import { defineModelFromTable, defineTable } from './support/internal' type Row = Record @@ -550,6 +551,160 @@ describe('model core slice', () => { ) }) + it('stores global relation registries on globalThis for bundled runtime module copies', () => { + const people = defineTable('people', { + id: column.id(), + name: column.string(), + }) + + const Person = defineModel(people) + const runtime = globalThis as typeof globalThis & { + __holoDbGlobalModels__?: Map>> + __holoDbMorphRegistry__?: Map>> + } + + expect(runtime.__holoDbGlobalModels__?.get('Person')).toBe(Person) + expect(runtime.__holoDbMorphRegistry__?.get('Person')).toBe(Person) + expect(listMorphModels()).toContain(Person) + + resetDB() + runtime.__holoDbGlobalModels__?.set('Person', Person) + runtime.__holoDbMorphRegistry__?.set('Person', Person) + + expect(getGlobalModel('Person')).toBe(Person) + expect(resolveMorphModel('Person')).toBe(Person) + }) + + it('keeps standalone model registries isolated from the global relation registry', () => { + const people = defineTable('people', { + id: column.id(), + name: column.string(), + }) + const animals = defineTable('animals', { + id: column.id(), + name: column.string(), + }) + + const Person = defineModel(people) + const Animal = defineModel(animals) + const registry = createModelRegistry() + + expect(registry.has('Person')).toBe(false) + expect(registry.register(Person)).toBe(Person.definition) + expect(registry.register(Person.definition)).toBe(Person.definition) + expect(registry.has('Person')).toBe(true) + expect(registry.get('Person')).toBe(Person.definition) + expect(registry.list()).toEqual([Person.definition]) + + const conflictingPerson = Object.freeze({ + ...Animal.definition, + name: 'Person', + morphClass: 'Person', + }) + expect(() => registry.register(conflictingPerson)).toThrow( + 'Model "Person" is already registered.', + ) + + const brokenPerson = Object.create(Person.definition, { + table: { + get() { + throw new Error('broken table metadata') + }, + }, + }) as typeof Person.definition + expect(() => registry.register(brokenPerson)).toThrow('broken table metadata') + + registry.clear() + expect(registry.list()).toEqual([]) + + const missingTableRegistry = createModelRegistry() + const missingGhost = Object.create(Person.definition, { + name: { value: 'Ghost' }, + primaryKey: { value: 'id' }, + morphClass: { value: 'ghost' }, + table: { + get() { + throw new SchemaError('Model "ghosts" is not present in the generated schema registry.') + }, + }, + }) as typeof Person.definition + const conflictingGhost = Object.create(Person.definition, { + name: { value: 'Ghost' }, + primaryKey: { value: 'id' }, + morphClass: { value: 'other-ghost' }, + table: { + get() { + throw new SchemaError('Model "ghosts" is not present in the generated schema registry.') + }, + }, + }) as typeof Person.definition + const matchingGhost = Object.create(Person.definition, { + name: { value: 'Ghost' }, + primaryKey: { value: 'id' }, + morphClass: { value: 'ghost' }, + table: { + get() { + throw new SchemaError('Model "ghosts" is not present in the generated schema registry.') + }, + }, + }) as typeof Person.definition + const conflictingGhostKey = Object.create(Person.definition, { + name: { value: 'Ghost' }, + primaryKey: { value: 'uuid' }, + morphClass: { value: 'ghost' }, + table: { + get() { + throw new SchemaError('Model "ghosts" is not present in the generated schema registry.') + }, + }, + }) as typeof Person.definition + + expect(missingTableRegistry.register(missingGhost)).toBe(missingGhost) + expect(missingTableRegistry.register(matchingGhost)).toBe(matchingGhost) + expect(() => missingTableRegistry.register(conflictingGhostKey)).toThrow( + 'Model "Ghost" is already registered.', + ) + expect(() => missingTableRegistry.register(conflictingGhost)).toThrow( + 'Model "Ghost" is already registered.', + ) + }) + + it('allows duplicate global registrations for the same model and rejects conflicting models', () => { + const people = defineTable('people', { + id: column.id(), + name: column.string(), + }) + const duplicatePeople = defineTable('people', { + id: column.id(), + name: column.string(), + }) + const animals = defineTable('animals', { + id: column.id(), + name: column.string(), + }) + + const Person = defineModel(people) + + expect(defineModel(duplicatePeople, { name: 'Person' }).definition.name).toBe('Person') + expect(getGlobalModel('Person')).toBe(Person) + expect(() => defineModel(animals, { name: 'Person' })).toThrow( + 'Model "Person" is already registered globally.', + ) + }) + + it('rethrows unresolved generated-schema errors while comparing duplicate model metadata', () => { + const Ghost = defineModel('ghosts', { + name: 'Ghost', + morphClass: 'ghost', + }) + + expect(() => defineModel('ghosts', { + name: 'Ghost', + morphClass: 'ghost', + })).toThrow('Model "ghosts" is not present in the generated schema registry.') + expect(getGlobalModel('Ghost')).toBe(Ghost) + }) + it('defers generated schema table resolution until the model is used', () => { const MissingUser = defineModel('missing_users') diff --git a/packages/db/tests/type-system.test.ts b/packages/db/tests/type-system.test.ts index d382abbe..d4f00db8 100644 --- a/packages/db/tests/type-system.test.ts +++ b/packages/db/tests/type-system.test.ts @@ -198,6 +198,126 @@ describe('type system contracts', () => { void updateEnum }) + it('infers model entity properties and get results from every logical column family', () => { + const examples = defineTable('entity_examples', { + id: column.id(), + integerValue: column.integer(), + bigIntegerValue: column.bigInteger(), + stringValue: column.string(), + textValue: column.text(), + booleanValue: column.boolean(), + realValue: column.real(), + decimalValue: column.decimal(), + dateValue: column.date(), + datetimeValue: column.datetime(), + timestampValue: column.timestamp(), + jsonValue: column.json<{ enabled: boolean, tags: string[] }>(), + blobValue: column.blob(), + uuidValue: column.uuid(), + ulidValue: column.ulid(), + snowflakeValue: column.snowflake(), + vectorValue: column.vector({ dimensions: 3 }), + enumValue: column.enum(['draft', 'published'] as const), + nullableId: column.id().nullable(), + nullableIntegerValue: column.integer().nullable(), + nullableBigIntegerValue: column.bigInteger().nullable(), + nullableStringValue: column.string().nullable(), + nullableTextValue: column.text().nullable(), + nullableBooleanValue: column.boolean().nullable(), + nullableRealValue: column.real().nullable(), + nullableDecimalValue: column.decimal().nullable(), + nullableDateValue: column.date().nullable(), + nullableDatetimeValue: column.datetime().nullable(), + nullableTimestampValue: column.timestamp().nullable(), + nullableJsonValue: column.json<{ enabled: boolean, tags: string[] }>().nullable(), + nullableBlobValue: column.blob().nullable(), + nullableUuidValue: column.uuid().nullable(), + nullableUlidValue: column.ulid().nullable(), + nullableSnowflakeValue: column.snowflake().nullable(), + nullableVectorValue: column.vector({ dimensions: 3 }).nullable(), + nullableEnumValue: column.enum(['draft', 'published'] as const).nullable(), + }) + + type ExampleEntity = Entity + + if (false) { + const entity = undefined as unknown as ExampleEntity + + expectTypeOf(entity.id).toEqualTypeOf() + expectTypeOf(entity.get('id')).toEqualTypeOf() + expectTypeOf(entity.integerValue).toEqualTypeOf() + expectTypeOf(entity.get('integerValue')).toEqualTypeOf() + expectTypeOf(entity.bigIntegerValue).toEqualTypeOf() + expectTypeOf(entity.get('bigIntegerValue')).toEqualTypeOf() + expectTypeOf(entity.stringValue).toEqualTypeOf() + expectTypeOf(entity.get('stringValue')).toEqualTypeOf() + expectTypeOf(entity.textValue).toEqualTypeOf() + expectTypeOf(entity.get('textValue')).toEqualTypeOf() + expectTypeOf(entity.booleanValue).toEqualTypeOf() + expectTypeOf(entity.get('booleanValue')).toEqualTypeOf() + expectTypeOf(entity.realValue).toEqualTypeOf() + expectTypeOf(entity.get('realValue')).toEqualTypeOf() + expectTypeOf(entity.decimalValue).toEqualTypeOf() + expectTypeOf(entity.get('decimalValue')).toEqualTypeOf() + expectTypeOf(entity.dateValue).toEqualTypeOf() + expectTypeOf(entity.get('dateValue')).toEqualTypeOf() + expectTypeOf(entity.datetimeValue).toEqualTypeOf() + expectTypeOf(entity.get('datetimeValue')).toEqualTypeOf() + expectTypeOf(entity.timestampValue).toEqualTypeOf() + expectTypeOf(entity.get('timestampValue')).toEqualTypeOf() + expectTypeOf(entity.jsonValue).toEqualTypeOf<{ enabled: boolean, tags: string[] }>() + expectTypeOf(entity.get('jsonValue')).toEqualTypeOf<{ enabled: boolean, tags: string[] }>() + expectTypeOf(entity.blobValue).toEqualTypeOf() + expectTypeOf(entity.get('blobValue')).toEqualTypeOf() + expectTypeOf(entity.uuidValue).toEqualTypeOf() + expectTypeOf(entity.get('uuidValue')).toEqualTypeOf() + expectTypeOf(entity.ulidValue).toEqualTypeOf() + expectTypeOf(entity.get('ulidValue')).toEqualTypeOf() + expectTypeOf(entity.snowflakeValue).toEqualTypeOf() + expectTypeOf(entity.get('snowflakeValue')).toEqualTypeOf() + expectTypeOf(entity.vectorValue).toEqualTypeOf() + expectTypeOf(entity.get('vectorValue')).toEqualTypeOf() + expectTypeOf(entity.enumValue).toEqualTypeOf<'draft' | 'published'>() + expectTypeOf(entity.get('enumValue')).toEqualTypeOf<'draft' | 'published'>() + expectTypeOf(entity.nullableId).toEqualTypeOf() + expectTypeOf(entity.get('nullableId')).toEqualTypeOf() + expectTypeOf(entity.nullableIntegerValue).toEqualTypeOf() + expectTypeOf(entity.get('nullableIntegerValue')).toEqualTypeOf() + expectTypeOf(entity.nullableBigIntegerValue).toEqualTypeOf() + expectTypeOf(entity.get('nullableBigIntegerValue')).toEqualTypeOf() + expectTypeOf(entity.nullableStringValue).toEqualTypeOf() + expectTypeOf(entity.get('nullableStringValue')).toEqualTypeOf() + expectTypeOf(entity.nullableTextValue).toEqualTypeOf() + expectTypeOf(entity.get('nullableTextValue')).toEqualTypeOf() + expectTypeOf(entity.nullableBooleanValue).toEqualTypeOf() + expectTypeOf(entity.get('nullableBooleanValue')).toEqualTypeOf() + expectTypeOf(entity.nullableRealValue).toEqualTypeOf() + expectTypeOf(entity.get('nullableRealValue')).toEqualTypeOf() + expectTypeOf(entity.nullableDecimalValue).toEqualTypeOf() + expectTypeOf(entity.get('nullableDecimalValue')).toEqualTypeOf() + expectTypeOf(entity.nullableDateValue).toEqualTypeOf() + expectTypeOf(entity.get('nullableDateValue')).toEqualTypeOf() + expectTypeOf(entity.nullableDatetimeValue).toEqualTypeOf() + expectTypeOf(entity.get('nullableDatetimeValue')).toEqualTypeOf() + expectTypeOf(entity.nullableTimestampValue).toEqualTypeOf() + expectTypeOf(entity.get('nullableTimestampValue')).toEqualTypeOf() + expectTypeOf(entity.nullableJsonValue).toEqualTypeOf<{ enabled: boolean, tags: string[] } | null>() + expectTypeOf(entity.get('nullableJsonValue')).toEqualTypeOf<{ enabled: boolean, tags: string[] } | null>() + expectTypeOf(entity.nullableBlobValue).toEqualTypeOf() + expectTypeOf(entity.get('nullableBlobValue')).toEqualTypeOf() + expectTypeOf(entity.nullableUuidValue).toEqualTypeOf() + expectTypeOf(entity.get('nullableUuidValue')).toEqualTypeOf() + expectTypeOf(entity.nullableUlidValue).toEqualTypeOf() + expectTypeOf(entity.get('nullableUlidValue')).toEqualTypeOf() + expectTypeOf(entity.nullableSnowflakeValue).toEqualTypeOf() + expectTypeOf(entity.get('nullableSnowflakeValue')).toEqualTypeOf() + expectTypeOf(entity.nullableVectorValue).toEqualTypeOf() + expectTypeOf(entity.get('nullableVectorValue')).toEqualTypeOf() + expectTypeOf(entity.nullableEnumValue).toEqualTypeOf<'draft' | 'published' | null>() + expectTypeOf(entity.get('nullableEnumValue')).toEqualTypeOf<'draft' | 'published' | null>() + } + }) + it('carries fluent create-table builder column types through the chain', () => { const table = new TableDefinitionBuilder('users') .id() diff --git a/tests/example-app-token-auth-flow.mjs b/tests/example-app-token-auth-flow.mjs new file mode 100644 index 00000000..01a929f4 --- /dev/null +++ b/tests/example-app-token-auth-flow.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' + +async function fetchJson(baseUrl, path, options = {}) { + const response = await fetch(new URL(path, baseUrl), { + method: options.method ?? 'GET', + headers: options.headers, + body: options.body, + redirect: 'manual', + }) + const text = await response.text() + + try { + return { + response, + json: JSON.parse(text), + } + } catch (error) { + throw new Error(`Expected JSON from ${path}: ${error instanceof Error ? error.message : String(error)}\n${text}`) + } +} + +async function fetchText(baseUrl, path) { + const response = await fetch(new URL(path, baseUrl), { + method: 'GET', + redirect: 'manual', + }) + const text = await response.text() + + return { + response, + text, + } +} + +async function createTokenFromCredentials(baseUrl) { + const formData = new FormData() + formData.set('email', 'editor@example.com') + formData.set('password', 'secret-secret') + + const result = await fetchJson(baseUrl, '/api/v1/tokens', { + method: 'POST', + body: formData, + }) + + assert.equal(result.response.status, 200) + assert.equal(result.json.ok, true) + assert.match(result.json.token, /^[^.]+\..+$/) + assert.equal(result.json.tokenId, result.json.token.split('.', 1)[0]) + assert.deepEqual(result.json.abilities, ['posts.read']) + + return result.json.token +} + +export async function assertExampleAppTokenAuthFlow({ baseUrl, expectedTitle }) { + const tokenPage = await fetchText(baseUrl, '/api-token-posts') + assert.equal(tokenPage.response.status, 200) + assert.match(tokenPage.text, /API token posts/i) + + const missingToken = await fetchJson(baseUrl, '/api/v1/posts') + assert.equal(missingToken.response.status, 401) + assert.equal(missingToken.json.ok, false) + assert.equal(missingToken.json.message, 'Unauthenticated.') + + const badToken = await fetchJson(baseUrl, '/api/v1/posts', { + headers: { + Authorization: 'Bearer bad-token', + }, + }) + assert.equal(badToken.response.status, 401) + assert.equal(badToken.json.ok, false) + + const invalidCredentials = new FormData() + invalidCredentials.set('email', 'editor@example.com') + invalidCredentials.set('password', 'wrong-secret') + const rejectedToken = await fetchJson(baseUrl, '/api/v1/tokens', { + method: 'POST', + body: invalidCredentials, + }) + assert.equal(rejectedToken.response.status, 401) + assert.equal(rejectedToken.json.ok, false) + assert.equal(rejectedToken.json.message, 'Invalid credentials.') + + const token = await createTokenFromCredentials(baseUrl) + const validToken = await fetchJson(baseUrl, '/api/v1/posts', { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + assert.equal(validToken.response.status, 200) + assert.equal(validToken.json.ok, true) + assert.ok(Array.isArray(validToken.json.posts)) + assert.ok(validToken.json.posts.some(post => post.title === expectedTitle)) +}