Authentication
Signing in is the job of an auth frond, built on better-auth. Your operations never handle a cookie: they ask who calls by the type of a parameter, and the answer reaches them in this process or another.
Add the auth frond
// fougere.config.ts
import { betterAuth } from '@fougere/auth-better';
import User from './fronds/user/entities/User';
export default defineFougere({
fronds: {
auth: betterAuth({
user: User,
secret: process.env.AUTH_SECRET!, // 32+ characters
baseUrl: process.env.SITE_URL ?? 'http://localhost:3000',
basePath: '/auth', // mounts /auth/** — sign-in, sign-up, sign-out…
providers: { credential: { minPasswordLength: 8, autoSignIn: true } },
}),
},
});
betterAuth() is a frond named auth. It holds the session, account and verification
tables — and user when you name none — so fougere migrate creates them and their writes are
checked like any frond's. Its extension rises only in the process that serves
the frond: it puts user and session on each call's state and mounts the better-auth endpoints. A
process that leaves the frond out with only: starts no auth engine.
basePath: the server answers everything under /auth/**. This site serves
its forms at /login and /register for that reason.Your user
Your User extends AuthUser — better-auth's fields, plus yours:
// fronds/user/entities/User.ts
import { AuthUser } from '@fougere/auth-better';
import { oneOf, optional } from '@fougere/schema';
export default class User extends AuthUser.extend({
role: optional(oneOf('admin', 'editor', 'reader')),
}).anchor() {}
The named class matters: the entity is registered as User, and a parameter user: User is matched
by that type. .anchor() gives the view its own rows.
Sign in from a page
The pages call better-auth's endpoints directly:
await $fetch('/auth/sign-up/email', { method: 'POST', body: { name, email, password } });
await $fetch('/auth/sign-in/email', { method: 'POST', body: { email, password } });
await $fetch('/auth/sign-out', { method: 'POST', body: {} });
// then: await refresh()
Who is signed in is an operation of the auth frond, session.get. useCurrentUser() asks it once
while the page renders on the server, and the answer is sent with the page — no request from the
browser:
const { user, refresh } = useCurrentUser();
// user: Ref<User | null>
// refresh: () => Promise<void> — read it again after signing in or out
<template>
<UButton v-if="user" :to="localePath('/blog/new')" :label="$t('nav.write')" />
<UButton v-else :to="localePath('/login')" :label="$t('nav.signIn')" />
</template>
What of the user reaches the page is what its entity lets out. Protecting a route is an ordinary middleware of your host reading the same session; Fougere adds no guard system of its own.
Know who calls
An operation asks for the user as a parameter. A collector fills it — a class extending
Collect<User>, in the frond's services/:
// fronds/blog/services/CurrentUserCollector.ts
import { Collect, FougereError, ErrorCode, type InvocationContext } from '@fougere/core';
import User from '@fronds/user/entities/User.js';
export default class CurrentUserCollector extends Collect<User> {
async collect(ctx: InvocationContext) {
return ctx.state.user as User | undefined; // put there by the auth frond
}
missing(): never {
throw new FougereError({ code: ErrorCode.UNAUTHORIZED, message: 'Sign in first' });
}
}
Then, in any handler of that frond:
async mine(user?: User): Promise<Post[]> { … } // nobody signed in: user is undefined
async publish(id: Post['id'], user: User): Promise<Post> { … } // nobody signed in: refused
A parameter without ? that nothing filled is refused before the method runs, with what
missing() throws — here 401. The handler writes no if (!user) throw, and the operation's
contract still promises the 401: the scan reads it from missing(). Without missing(), the
refusal is 422 user: Required. missing() receives the call, so it can tell nobody signed in from
a session that expired.
The same user reaches every reader:
| Reader | How the user arrives |
|---|---|
| a page | session.get, asked while rendering on the server — useCurrentUser() |
| a handler | the call's state → the collector fills user: User |
| a frond in another process | the state travels with the call — its collector receives the same user |
The browser cannot write this state: it is built on the server from the request.
The rules of a collector
- It matches by type.
Collect<User>fillsuser?: User,user: User | undefined, and any alias meaning the same. - Its target needs no schema.
Collect<Ability>works for a value you build from the state at each call. - It runs for a call that comes through a door — a page, REST, GraphQL. Code calling another
frond's facade already holds the user and passes it:
publish(id, user). - It takes dependencies in its constructor, like any class.
- It is a port. A link may stand in front of it — a cache, an audit:
export default class CachedUser implements Partial<Collect<User>> { constructor(private inner: Collect<User>, private cache: SessionCache) {} async collect(ctx: InvocationContext) { return this.cache.get(ctx) ?? this.inner.collect(ctx); } }
What it leaves out,missing()included, is the collector's. Two collectors of one type refuse the boot;ports: { 'Collect<User>': 'CurrentUserCollector' }says which answers.
It does not cross a frond
A collector fills parameters in its own frond, and in the fronds that extend it — nowhere else. Keep one in each frond that needs it, or in the frond they all extend.
- A collector for that type in another frond is not used: the parameter is read from the
request, and the boot warns
collector-in-another-frond, with both ways out. - With no collector for that type anywhere, the parameter is an ordinary input, read from the body.
When reading it from the request is what you mean, say so for that parameter:
// fronds/admin/frond.config.ts — the invited user comes from the request
export default {
operations: { 'invite.inviteUser': { binding: [{ name: 'user', source: { kind: 'input' } }] } },
};
user?: User in a project with no Collect<User> receives whatever the client sent. A handler that
checks user.role would be checking a value the caller chose.What the state may hold
ctx.state is declared, by the extensions of the process: betterAuth() declares user and
session (the session without its token — a process trusts the process that called it, never the
cookie behind it). Your own extension declares its members with fields:
extensions: [{ name: 'tenancy', state: { tenant: text() } }]
The state is checked where it entered — in the process whose host filled it, or whose host middleware wrote a member:
- a member nobody declares is refused:
state.tenant: Unknown field — this process declares user, session; - two extensions declaring one member refuse the boot;
- a process receiving the call checks nothing more: it was checked where it entered, and the signed envelope proves which process wrote it.
A collector hands on what it read as its type, checked and rebuilt: state.user.since is a
Date in this process and across the wire alike, never the string JSON makes of it.