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.

Do not put pages under 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:

ReaderHow the user arrives
a pagesession.get, asked while rendering on the server — useCurrentUser()
a handlerthe call's state → the collector fills user: User
a frond in another processthe 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> fills user?: 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.

Built with Fougere and Nuxt, hosted on GitHub Pages.