Sessions without a signup wall

cloud.auth

Sessions are stateless and HMAC-signed, carried in an httpOnly cookie the browser cannot read. There is no session table, so signing a request costs no database round trip.

One deployment can serve several apps. Set an `apps` allow-list on the server and pass `appId` to createClient, and each app gets its own fs and kv namespace while sharing one set of accounts — somebody signs in once and every app of yours knows them, but your notes app cannot read your photo app’s files.

Anonymous sessions mint a real, scoped user id, so a demo or single-user tool works on first load with no signup screen.

Signing up from an anonymous session keeps that same id and attaches credentials to it. Because fs and kv are namespaced by user id, keeping the id *is* the migration — the files someone wrote as a visitor are still there once they have an account.

Editor
Output
Press Run.
Network — real requests to this site’s own backend
No requests yet.

ensureUser() → PublicUser

Returns the current user, signing in anonymously if there is none. Call this at startup and the other namespaces just work.

getUser() → PublicUser | null

Null when signed out. Never throws for that case.

signUp(email, password)

Argon2id at the OWASP parameters. Rate limited per IP, and a duplicate address is a conflict error. Called from an anonymous session it upgrades that account in place rather than creating a second one; called while already signed in to a real account it is a conflict.

signIn(email, password)

An unknown address and a wrong password return the identical error, so the endpoint cannot be used to discover who has an account.

signInAnonymously()

A fresh scoped identity with no credentials.

signOut()

Clears the cookie.

Using Clerk, Auth0 or another provider

perusta does not care who vouched for a person, only that something did. Give it a resolveUser hook and it takes their identity from your provider instead of its own cookie — the storage namespacing is unchanged, because it only ever needed a stable user id.

Return null and it falls back to perusta’s own sessions, so anonymous visitors keep working alongside signed-in ones. The hook runs on every request, so a revoked token stops working immediately rather than at the end of a session.

If it throws, the request is treated as signed out. An auth check that errors must never be read as a pass.

// app/api/perusta/[...route]/route.ts
import { auth } from '@clerk/nextjs/server';
import { perustaFromEnv } from 'perusta-server';
import { toRouteHandlers } from 'perusta-server/next';

export const { GET, POST, OPTIONS } = toRouteHandlers(
  perustaFromEnv({
    auth: {
      resolveUser: async () => {
        const { userId } = await auth();
        return userId ? { userId } : null;
      },
      // Clerk owns accounts now, so turn perusta's own signup off.
      allowSignUp: false,
    },
  }),
);

What perusta stores for an external user

Nothing. Your provider owns the account; perusta only namespaces files and keys under the id you return. `getUser()` answers from what the hook told it rather than looking anything up.

The id has to be stable across sessions — use the provider’s user id, never an email or a session id. Change it and that person’s files are simply somewhere else.