Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ Learn DSA the way senior engineers actually use it — not isolated leetcode puz

## Before you turn on Multi-user

Multi-user Mode (accounts, each keeping its own progress) arrives in the next update. Setup says so until then. Set these up first, so your Instance is ready when it does. They go in the `.env` next to `compose.yaml`.
Multi-user Mode (accounts, each keeping its own progress) can be chosen at Setup. Set these up first, so Setup can offer it. They go in the `.env` next to `compose.yaml`.

- **`BETTER_AUTH_SECRET` is required.** It signs sessions. Use at least 32 characters, and not the example value from `.env.example`. `init` writes a random one for you. `openssl rand -hex 24` makes another.
- **`ORIGIN` is required.** It is the public URL of your Instance, for example `https://learn.example.com`. Setup does not offer Multi-user without it, and a Multi-user Instance will not start without it. `PROTOCOL_HEADER` and `HOST_HEADER` do not replace it.
- **Behind a reverse proxy, set `ORIGIN` plus `ADDRESS_HEADER` and `XFF_DEPTH`.** `ADDRESS_HEADER` is the header your proxy fills in with the client's address, such as `X-Forwarded-For`. `XFF_DEPTH` is how many trusted proxies sit in front of the app, as the [SvelteKit `adapter-node` docs](https://svelte.dev/docs/kit/adapter-node#Environment-variables-ADDRESS_HEADER-and-XFF_DEPTH) describe. Without them every client looks like the proxy, and they all share one sign-in rate limit.
- **If you set those headers, only the proxy may reach the app's port.** A client that goes round the proxy can forge the header, so it gets a fresh sign-in limit on every request, or locks out someone else by naming their address. Docker [publishes a port on every interface](https://docs.docker.com/engine/network/port-publishing/) and [gets past ufw](https://docs.docker.com/engine/network/packet-filtering-firewalls/). So change the `ports` line in `compose.yaml` to `"127.0.0.1:${PORT}:3000"` if the proxy runs on the same machine, or keep the app on the proxy's Docker network only. Use Docker 28.0.0 or later: older releases let hosts on the same network segment reach a port published to `127.0.0.1`. If clients connect straight to the app, set neither header and leave the port binding alone.
- **Open Setup straight after the first start.** On a fresh Instance the first visitor to Setup chooses the mode. When Multi-user is available, whoever chooses it first creates the admin Account and becomes the admin.
- **Open Setup straight after the first start.** On a fresh Instance the first visitor to Setup chooses the mode. Whoever chooses Multi-user first creates the admin Account and becomes the admin.
- **Use https if the Instance is reachable beyond your own machine.** Over plain http, passwords and session cookies cross the network unencrypted.
- **If an older `init` wrote your `compose.yaml`,** edit `web`'s `environment` and then run `docker compose up -d`. Replace `ORIGIN: ${ORIGIN:-}` with a bare `ORIGIN:` line: the old line passes an empty `ORIGIN` when `.env` has none, and [an empty `ORIGIN` stops the server](https://github.com/sveltejs/kit/blob/fef182780ecfc5eecd1c12e03afb99af9d1f7049/packages/adapter-node/utils.js#L26-L35). Behind a proxy, add `ADDRESS_HEADER: ${ADDRESS_HEADER:-}` and `XFF_DEPTH: ${XFF_DEPTH:-1}` too. Without them the headers you set in `.env` never reach the app.
- **Never set** `BETTER_AUTH_SECRETS`, `BETTER_AUTH_URL`, `PUBLIC_BETTER_AUTH_URL`, `BASE_URL`, `BETTER_AUTH_TRUSTED_ORIGINS`, `BETTER_AUTH_TELEMETRY` or `BETTER_AUTH_TELEMETRY_ENDPOINT`. Better Auth, the sign-in library, reads each from the environment on its own. [`BETTER_AUTH_SECRETS`](https://github.com/better-auth/better-auth/blob/db02f233918ad1233bf0753e437e1c0da353273d/packages/better-auth/src/context/create-context.ts#L174) replaces the session secret. [`BETTER_AUTH_TRUSTED_ORIGINS`](https://github.com/better-auth/better-auth/blob/db02f233918ad1233bf0753e437e1c0da353273d/packages/better-auth/src/context/helpers.ts#L155) adds origins it accepts. The app refuses to start while either is set. [The URL variables](https://github.com/better-auth/better-auth/blob/db02f233918ad1233bf0753e437e1c0da353273d/packages/better-auth/src/utils/url.ts#L153-L160) name another base URL, and [the telemetry pair](https://github.com/better-auth/better-auth/blob/db02f233918ad1233bf0753e437e1c0da353273d/packages/telemetry/src/index.ts#L26) sends Better Auth's telemetry to the endpoint you give. Keep those five unset too.
Expand Down
38 changes: 30 additions & 8 deletions src/lib/server/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,40 @@ const bootMulti = (url: string, extra: Record<string, string | undefined> = {})
});

describe('Multi-user', () => {
it('is a later release on a server without the test switch, with or without a secret', async () => {
it('can be chosen on a server without the test switch once the secret and origin are set, and signs the admin in', async () => {
const { url, db } = await migratedDatabase();
const app = await boot(url, { BETTER_AUTH_SECRET: secret, ORIGIN: 'http://localhost:4174' });
expect(app.setup.multiUser).toEqual({ kind: 'open' });

const { headers } = await app.setup.createFirstAdmin(admin);

expect(app.instance.mode()).toBe('multi');
const cookie = headers
.getSetCookie()
.map((c) => c.split(';')[0])
.join('; ');
const who = await app.multiUser?.identity.resolve({
cookies: { get: () => undefined },
request: { headers: new Headers({ cookie }) }
});
expect(who).toMatchObject({ displayName: 'Ada', role: 'admin', learnerId: 'local' });
expect(await db.learner.findMany()).toEqual([expect.objectContaining({ id: 'local' })]);
}, 30_000);

it('says what to add to .env on a server without the test switch and without a secret', async () => {
const { url } = await migratedDatabase();

for (const extra of [{}, { BETTER_AUTH_SECRET: secret, ORIGIN: 'http://localhost:4174' }]) {
const app = await boot(url, extra);
expect(app.setup.multiUser).toEqual({ kind: 'later' });
await expect(app.setup.createFirstAdmin(admin)).rejects.toThrow(/not available/);
}
expect((await boot(url)).instance.mode()).toBeNull();
const app = await boot(url);

expect(app.setup.multiUser).toEqual({
kind: 'needs-env',
message: expect.stringMatching(/BETTER_AUTH_SECRET.*\.env/s)
});
await expect(app.setup.createFirstAdmin(admin)).rejects.toThrow(/not available/);
expect(app.instance.mode()).toBeNull();
}, 30_000);

it('can be chosen on the test server once the secret and origin are set, and signs the admin in', async () => {
it('can be chosen on the test server too, once the secret and origin are set', async () => {
const { url, db } = await migratedDatabase();
const app = await bootMulti(url);
expect(app.setup.multiUser).toEqual({ kind: 'open' });
Expand Down
15 changes: 9 additions & 6 deletions src/lib/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import { createCatalogueScriptedRunner } from './runner/scripted-catalogue.ts';
/** Whether Setup can offer Multi-user on this server, and if not, why. */
export type MultiUserChoice =
| { kind: 'open' }
/** Released builds, until Sign in exists (C32): only the browser tests' server can choose it. */
/**
* Never produced since C32: every Instance with what Better Auth needs is `open`. Only still named
* because test stubs outside Setup's files (`signup-gate.test.ts`, `users.test.ts`) build one; delete
* the variant, and the handling of it in Setup, once they stop.
*/
| { kind: 'later' }
/** `message` says what to add to `.env`. */
| { kind: 'needs-env'; message: string };
Expand Down Expand Up @@ -104,9 +108,9 @@ export async function createApp(env: {
identity,
multiUser,
setup: {
multiUser: choice(env, auth),
multiUser: choice(auth),
async createFirstAdmin(input) {
if (choice(env, auth).kind !== 'open' || auth.kind !== 'built') {
if (auth.kind !== 'built') {
throw new MultiUserNotAvailableError();
}
return createFirstAdmin({ instance, auth: auth.auth, db }, input);
Expand Down Expand Up @@ -201,9 +205,8 @@ function originOf(value: string | undefined): string | null {

const chooseMultiUser = (auth: Auth) => ({ auth, identity: createMultiUserIdentity(auth) });

/** Released builds keep Multi-user off until Sign in exists (C32 removes the test-switch condition). */
function choice(env: { IDENTITY_TEST_LEARNER?: string }, auth: BuiltAuth): MultiUserChoice {
if (env.IDENTITY_TEST_LEARNER !== '1') return { kind: 'later' };
/** Multi-user is open to every Instance that has what Better Auth needs; otherwise Setup says what to add. */
function choice(auth: BuiltAuth): MultiUserChoice {
return auth.kind === 'built' ? { kind: 'open' } : { kind: 'needs-env', message: auth.message };
}

Expand Down
127 changes: 122 additions & 5 deletions src/lib/server/handle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ async function setup(options: { local?: boolean } = {}) {
await identity.ensureLocalLearner();
}
const core = { forLearner: (learnerId: string) => ({ learnerId }) } as unknown as App['core'];
const setupPort = { multiUser: { kind: 'later' }, createFirstAdmin: vi.fn() } as App['setup'];
const setupPort = { multiUser: { kind: 'open' }, createFirstAdmin: vi.fn() } as App['setup'];
return {
db,
handle: createHandle({ instance, identity, core, multiUser: null, setup: setupPort })
Expand Down Expand Up @@ -241,7 +241,7 @@ describe('Multi-user Mode', () => {

it('lets the auth route through to a signed-out visitor, with the Better Auth instance on locals', async () => {
const { handle, asking, auth } = await multiSetup();
const { event, locals, resolve } = asking('/api/auth/sign-in/email', '/api/auth/[...all]', '');
const { event, locals, resolve } = asking('/api/auth/get-session', '/api/auth/[...all]', '');

const response = await handle({ event, resolve });

Expand All @@ -253,7 +253,7 @@ describe('Multi-user Mode', () => {

it('still refuses a remote call that claims the auth path', async () => {
const { handle, asking } = await multiSetup();
const { event, resolve } = asking('/api/auth/sign-in/email', '/api/auth/[...all]', '');
const { event, resolve } = asking('/api/auth/get-session', '/api/auth/[...all]', '');
(event as { isRemoteRequest: boolean }).isRemoteRequest = true;

const response = await handle({ event, resolve });
Expand Down Expand Up @@ -378,9 +378,23 @@ describe('Multi-user Mode', () => {
expect(response.headers.get('location')).toBe('/');
});

it('answers an unmatched path as the gate says (the 404 page for everyone), with the headers', async () => {
it('treats a path with no route as a private page: a signed-out visitor is sent to Sign in, not shown the 404', async () => {
const { handle, asking } = await multiSetup();
const { event, resolve } = asking('/no/such/page', null, '');
const { event, resolve } = asking('/no/such/page?x=1', null, '');

const response = await handle({ event, resolve });

expect(response.status).toBe(303);
expect(response.headers.get('location')).toBe(
`/signin?next=${encodeURIComponent('/no/such/page?x=1')}`
);
expect(Object.fromEntries(response.headers)).toMatchObject(framing);
expect(resolve).not.toHaveBeenCalled();
});

it('shows a signed-in user the 404 page for a path with no route, with the headers', async () => {
const { handle, asking } = await multiSetup();
const { event, resolve } = asking('/no/such/page', null);

const response = await handle({ event, resolve });

Expand All @@ -389,6 +403,109 @@ describe('Multi-user Mode', () => {
expect(Object.fromEntries(response.headers)).toMatchObject(framing);
});

it('sends a user who must change their password from a path with no route to that page', async () => {
const { handle, asking, db } = await multiSetup();
await db.user.updateMany({ data: { mustChangePassword: true } });
const { event, resolve } = asking('/no/such/page', null);

const response = await handle({ event, resolve });

expect(response.status).toBe(303);
expect(response.headers.get('location')).toBe('/account/password');
expect(resolve).not.toHaveBeenCalled();
});

it('does not send a user who must change their password round in a loop while /account/password has no route', async () => {
const { handle, asking, db } = await multiSetup();
await db.user.updateMany({ data: { mustChangePassword: true } });
const { event, resolve } = asking('/account/password', null);

const response = await handle({ event, resolve });

// Where the redirect points, they arrive: the 404 page renders, nothing redirects again.
expect(response.status).toBe(200);
expect(resolve).toHaveBeenCalledOnce();
});

it('keeps the Settings load data from a user who must change their password, sign-out action or not', async () => {
const { handle, db, cookie } = await multiSetup();
await db.user.updateMany({ data: { mustChangePassword: true } });
// As SvelteKit builds it for POST /settings/__data.json?/signout: the suffix is already off event.url
// (respond.js strips it) and event.isDataRequest says what it was.
const { event, resolve } = request(
'/settings?/signout',
'/settings',
'POST',
new Headers({ cookie })
);
(event as { isDataRequest: boolean }).isDataRequest = true;

const response = await handle({ event, resolve });

expect(response.status).toBe(303);
expect(response.headers.get('location')).toBe('/account/password');
expect(resolve).not.toHaveBeenCalled();
});

it('still serves a signed-in user a page\u2019s load data', async () => {
const { handle, asking } = await multiSetup();
const data = asking('/settings', '/settings');
(data.event as { isDataRequest: boolean }).isDataRequest = true;

const response = await handle({ event: data.event, resolve: data.resolve });

expect(response.status).toBe(200);
expect(data.resolve).toHaveBeenCalledOnce();
});

it('still lets a user who must change their password sign out, from the nav and from Settings', async () => {
const { handle, db, cookie } = await multiSetup();
await db.user.updateMany({ data: { mustChangePassword: true } });
for (const [path, routeId] of [
['/settings?/signout', '/settings'],
['/api/auth/sign-out', '/api/auth/[...all]']
] as const) {
const { event, resolve } = request(path, routeId, 'POST', new Headers({ cookie }));

const response = await handle({ event, resolve });

expect(response.status, path).toBe(200);
expect(resolve, path).toHaveBeenCalledOnce();
}
// Any other action of that page is still held back.
const reset = request('/settings?/reset', '/settings', 'POST', new Headers({ cookie }));
const held = await handle({ event: reset.event, resolve: reset.resolve });
expect(held.status).toBe(303);
expect(held.headers.get('location')).toBe('/account/password');
});

it('does not set the refreshed session cookie over the one a sign-out clears', async () => {
const { handle, db, cookie } = await multiSetup();
await db.session.updateMany({
data: { expiresAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000) }
});
const { event, resolve } = request(
'/settings?/signout',
'/settings',
'POST',
new Headers({ cookie })
);
resolve.mockImplementation(async () => {
const response = new Response(null, { status: 303, headers: { location: '/signin' } });
response.headers.append(
'set-cookie',
'better-auth.session_token=; Max-Age=0; Path=/; HttpOnly'
);
return response;
});

const response = await handle({ event, resolve });

expect(response.headers.getSetCookie()).toEqual([
'better-auth.session_token=; Max-Age=0; Path=/; HttpOnly'
]);
});

it('fails closed when a multi Instance has no Better Auth', async () => {
const db = await createTestDb();
const instance = await createInstance(db);
Expand Down
1 change: 1 addition & 0 deletions src/lib/server/handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function createHandle(
who,
mustChangePassword: who?.mustChangePassword,
routeId: event.route.id,
isDataRequest: event.isDataRequest,
method: request.method,
pathname: url.pathname,
search: url.search
Expand Down
Loading