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
16 changes: 16 additions & 0 deletions prisma/migrations/20261016000000_invite/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "Invite" (
"id" TEXT NOT NULL PRIMARY KEY,
"tokenHash" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"usedAt" DATETIME,
"createdByUserId" TEXT NOT NULL,
CONSTRAINT "Invite_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

-- CreateIndex
CREATE UNIQUE INDEX "Invite_tokenHash_key" ON "Invite"("tokenHash");

-- CreateIndex
CREATE INDEX "Invite_createdByUserId_idx" ON "Invite"("createdByUserId");
14 changes: 14 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ model User {
mustChangePassword Boolean @default(false)
sessions Session[]
accounts Account[]
invites Invite[]
}

model Session {
Expand Down Expand Up @@ -151,3 +152,16 @@ model Verification {

@@index([identifier])
}

/// An invite link for one new account. Only the SHA-256 of the token is stored; the link is shown once.
model Invite {
id String @id
tokenHash String @unique
createdAt DateTime @default(now())
expiresAt DateTime
usedAt DateTime?
createdByUserId String
createdBy User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade)

@@index([createdByUserId])
}
3 changes: 3 additions & 0 deletions src/app.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Setup } from '$lib/server/app';
import type { Identity } from '$lib/server/identity';
import type { Auth } from '$lib/server/identity/auth';
import type { Invites } from '$lib/server/identity/invites';
import type { Instance } from '$lib/server/instance';
import type { Learner } from '$lib/server/learning/core';

Expand All @@ -19,6 +20,8 @@ declare global {
setup: Setup;
/** Better Auth; set only in Multi-user Mode, where the auth route mounts its handler. */
auth?: Auth;
/** Invite links; set exactly when `auth` is (Multi-user Mode), so Local Mode never has them. */
invites?: Invites;
/** Who this request is, from Identity. Unset only on /setup before the Instance is set up. */
identity: Identity;
/** The Learning core acting for this request's Learner. Unset exactly when `identity` is. */
Expand Down
3 changes: 3 additions & 0 deletions src/lib/server/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ describe('Multi-user', () => {
request: { headers: new Headers({ cookie }) }
});
expect(who).toMatchObject({ displayName: 'Ada', role: 'admin', learnerId: 'local' });
// Invite links carry the validated ORIGIN, the one Better Auth uses.
const made = await app.multiUser?.invites.create({ who: who as never, now: new Date() });
expect(made?.link).toBe(`http://localhost:4174/invite/${made?.token}`);
expect(await db.learner.findMany()).toEqual([expect.objectContaining({ id: 'local' })]);
}, 30_000);

Expand Down
17 changes: 13 additions & 4 deletions src/lib/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type Catalogue, loadCatalogue } from './content/catalogue.ts';
import { createDb, type Db } from './db.ts';
import { type Auth, createAuth, isPlaceholderSecret, MIN_SECRET_LENGTH } from './identity/auth.ts';
import { createLocalIdentity, type IdentityResolver } from './identity/index.ts';
import { createInvites, type Invites } from './identity/invites.ts';
import { createMultiUserIdentity } from './identity/multi-user.ts';
import { createRegistration } from './identity/registration.ts';
import {
Expand Down Expand Up @@ -41,7 +42,7 @@ export interface App {
/** The Instance's mode, read by the hook on every request. One per server: it caches the mode. */
instance: Instance;
/** Better Auth and the Multi-user adapter; null when the secret or the origin is not set. */
multiUser: { auth: Auth; identity: IdentityResolver } | null;
multiUser: { auth: Auth; identity: IdentityResolver; invites: Invites } | null;
setup: Setup;
}

Expand Down Expand Up @@ -79,7 +80,7 @@ export async function createApp(env: {
const db = createDb(env.DATABASE_URL);
const instance = await createInstance(db);
const auth = buildAuth(env, db, instance);
const multiUser = auth.kind === 'built' ? chooseMultiUser(auth.auth) : null;
const multiUser = auth.kind === 'built' ? chooseMultiUser(auth, db) : null;
if (instance.mode() === 'multi') {
// The test cookie is read only by the Local adapter, which a multi-user Instance never consults;
// refuse the combination anyway. The browser tests' Multi-user server boots empty and becomes multi.
Expand Down Expand Up @@ -146,7 +147,9 @@ function refuseAuthEnvOverrides(): void {
}
}

type BuiltAuth = { kind: 'built'; auth: Auth } | { kind: 'missing'; message: string };
type BuiltAuth =
| { kind: 'built'; auth: Auth; origin: string }
| { kind: 'missing'; message: string };

/**
* Better Auth, when the secret is set and long enough and ORIGIN is a real http(s) URL. Never with an
Expand Down Expand Up @@ -190,6 +193,7 @@ function buildAuth(
}
return {
kind: 'built',
origin,
auth: createAuth({ db, origin, secret, admitSignUp: createRegistration({ instance }) })
};
}
Expand All @@ -203,7 +207,12 @@ function originOf(value: string | undefined): string | null {
}
}

const chooseMultiUser = (auth: Auth) => ({ auth, identity: createMultiUserIdentity(auth) });
const chooseMultiUser = (built: Extract<BuiltAuth, { kind: 'built' }>, db: Db) => ({
auth: built.auth,
identity: createMultiUserIdentity(built.auth),
// Invite links name the same ORIGIN the cookies and the origin check use.
invites: createInvites({ db, origin: built.origin })
});

/** Multi-user is open to every Instance that has what Better Auth needs; otherwise Setup says what to add. */
function choice(auth: BuiltAuth): MultiUserChoice {
Expand Down
19 changes: 18 additions & 1 deletion src/lib/server/handle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { App } from './app.ts';
import { createHandle } from './handle.ts';
import { createAuth } from './identity/auth.ts';
import { createLocalIdentity } from './identity/index.ts';
import { createInvites } from './identity/invites.ts';
import { createMultiUserIdentity } from './identity/multi-user.ts';
import { createRegistration } from './identity/registration.ts';
import { createFirstAdmin } from './identity/setup.ts';
Expand Down Expand Up @@ -192,7 +193,11 @@ describe('Multi-user Mode', () => {
.join('; ');
const identity = await createLocalIdentity(db, options);
const core = { forLearner: (learnerId: string) => ({ learnerId }) } as unknown as App['core'];
const multiUser = { auth, identity: createMultiUserIdentity(auth) };
const multiUser = {
auth,
identity: createMultiUserIdentity(auth),
invites: createInvites({ db, origin: 'http://localhost:3000' })
};
const setupPort = { multiUser: { kind: 'open' }, createFirstAdmin: vi.fn() } as App['setup'];
return {
db,
Expand All @@ -204,6 +209,18 @@ describe('Multi-user Mode', () => {
};
}

it('puts the Invites on locals in Multi-user Mode, and never in Local Mode', async () => {
const { handle, asking } = await multiSetup();
const { event, locals, resolve } = asking('/', '/');
await handle({ event, resolve });
expect(locals.invites).toBeDefined();

const local = await setup({ local: true });
const asked = request('/', '/');
await local.handle({ event: asked.event, resolve: asked.resolve });
expect(asked.locals.invites).toBeUndefined();
});

it('resolves the session cookie to its Account and binds the Learning core to its Learner', async () => {
const { handle, asking } = await multiSetup();
const { event, locals, resolve } = asking('/', '/');
Expand Down
15 changes: 9 additions & 6 deletions src/lib/server/handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function createHandle(
// Cannot happen at start (createApp refuses it); fail closed rather than serve nobody.
if (!app.multiUser) return refuseFraming(refusal(503));
event.locals.auth = app.multiUser.auth;
event.locals.invites = app.multiUser.invites;
who = await app.multiUser.identity.resolve(event);
}
// An active session was extended: its new cookie goes out with whatever this request answers (a
Expand Down Expand Up @@ -78,12 +79,14 @@ export function createHandle(
}
// No theme cookie means follow the device; a choice goes on <html> in the server render, so no flash.
// Read when the page renders, not before: an action that just set the cookie is seen by its own response.
return finish(
await resolve(event, {
transformPageChunk: ({ html }) =>
withTheme(html, parseTheme(event.cookies.get(THEME_COOKIE)))
})
);
const response = await resolve(event, {
transformPageChunk: ({ html }) => withTheme(html, parseTheme(event.cookies.get(THEME_COOKIE)))
});
// Users shows everyone's names and emails and the one-time Invite link: never cached, whatever
// answers (the page, a data request, an action result, an error). Set here, once, because a
// route that sets it in both its action and its load makes SvelteKit throw on a no-JS form post.
if (event.route.id === '/users') response.headers.set('Cache-Control', 'no-store');
return finish(response);
};
}

Expand Down
150 changes: 150 additions & 0 deletions src/lib/server/identity/invites.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest';
import { createTestDb } from '../test-db';
import { NotAdminError } from './accounts';
import { createInvites, hashToken, INVITE_LIFETIME_MS } from './invites';

const origin = 'https://learn.example.com';
const now = new Date('2026-10-16T12:00:00.000Z');
const admin = { learnerId: 'L-admin', displayName: 'Ada', role: 'admin' as const };
const learner = { learnerId: 'L-lin', displayName: 'Lin', role: 'learner' as const };

async function setup() {
const db = await createTestDb();
await db.learner.create({ data: { id: admin.learnerId } });
await db.user.create({
data: {
id: 'u-admin',
name: 'Ada',
email: 'ada@example.com',
role: 'admin',
learnerId: 'L-admin'
}
});
return { db, invites: createInvites({ db, origin }) };
}

describe('create', () => {
it('returns a 43-character base64url token and a link on the origin', async () => {
const { invites } = await setup();
const made = await invites.create({ who: admin, now });
expect(made.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(made.link).toBe(`${origin}/invite/${made.token}`);
});

it('stores only the SHA-256 of the token, valid for 7 days', async () => {
const { db, invites } = await setup();
const made = await invites.create({ who: admin, now });
const rows = await db.invite.findMany();
expect(rows).toHaveLength(1);
expect(rows[0].tokenHash).toBe(hashToken(made.token));
expect(rows[0].tokenHash).not.toContain(made.token);
expect(JSON.stringify(rows)).not.toContain(made.token);
expect(rows[0].createdByUserId).toBe('u-admin');
expect(rows[0].usedAt).toBeNull();
expect(rows[0].expiresAt.getTime() - now.getTime()).toBe(INVITE_LIFETIME_MS);
expect(made.expiresAt).toBe(rows[0].expiresAt.toISOString());
});

it('makes a different token each time', async () => {
const { invites } = await setup();
const a = await invites.create({ who: admin, now });
const b = await invites.create({ who: admin, now });
expect(a.token).not.toBe(b.token);
});

it('refuses a learner and writes nothing', async () => {
const { db, invites } = await setup();
await expect(invites.create({ who: learner, now })).rejects.toBeInstanceOf(NotAdminError);
expect(await db.invite.count()).toBe(0);
});

it('is removed with the admin who made it', async () => {
const { db, invites } = await setup();
await invites.create({ who: admin, now });
await db.user.delete({ where: { id: 'u-admin' } });
expect(await db.invite.count()).toBe(0);
});
});

describe('listPending', () => {
it('lists unused, unexpired Invites without the token or its hash', async () => {
const { db, invites } = await setup();
const made = await invites.create({ who: admin, now });
const rows = await invites.listPending({ who: admin, now });
expect(rows).toEqual([
{ id: made.id, createdAt: now.toISOString(), expiresAt: made.expiresAt }
]);
const text = JSON.stringify(rows);
expect(text).not.toContain(made.token);
const stored = await db.invite.findFirstOrThrow();
expect(text).not.toContain(stored.tokenHash);
});

it('leaves out used and expired Invites', async () => {
const { db, invites } = await setup();
const used = await invites.create({ who: admin, now });
await db.invite.update({ where: { id: used.id }, data: { usedAt: now } });
await invites.create({ who: admin, now });
const later = new Date(now.getTime() + INVITE_LIFETIME_MS);
expect(await invites.listPending({ who: admin, now })).toHaveLength(1);
expect(await invites.listPending({ who: admin, now: later })).toHaveLength(0);
});

it('refuses a learner', async () => {
const { invites } = await setup();
await expect(invites.listPending({ who: learner, now })).rejects.toBeInstanceOf(NotAdminError);
});
});

describe('cancel', () => {
it('deletes a pending Invite, and its link no longer works', async () => {
const { invites } = await setup();
const made = await invites.create({ who: admin, now });
expect(await invites.cancel({ who: admin, id: made.id })).toBe(true);
expect(await invites.lookup(made.token, now)).toBeNull();
expect(await invites.listPending({ who: admin, now })).toEqual([]);
});

it('is safe for an unknown, cancelled or used Invite', async () => {
const { db, invites } = await setup();
const made = await invites.create({ who: admin, now });
await invites.cancel({ who: admin, id: made.id });
expect(await invites.cancel({ who: admin, id: made.id })).toBe(false);
expect(await invites.cancel({ who: admin, id: 'nope' })).toBe(false);
const used = await invites.create({ who: admin, now });
await db.invite.update({ where: { id: used.id }, data: { usedAt: now } });
expect(await invites.cancel({ who: admin, id: used.id })).toBe(false);
expect(await db.invite.count()).toBe(1);
});

it('refuses a learner and deletes nothing', async () => {
const { db, invites } = await setup();
const made = await invites.create({ who: admin, now });
await expect(invites.cancel({ who: learner, id: made.id })).rejects.toBeInstanceOf(
NotAdminError
);
expect(await db.invite.count()).toBe(1);
});
});

describe('lookup', () => {
it('finds a pending Invite by its token', async () => {
const { invites } = await setup();
const made = await invites.create({ who: admin, now });
expect(await invites.lookup(made.token, now)).toEqual({ id: made.id });
});

it('refuses a missing, malformed, used or expired token', async () => {
const { db, invites } = await setup();
const made = await invites.create({ who: admin, now });
expect(await invites.lookup('A'.repeat(43), now)).toBeNull();
expect(await invites.lookup('', now)).toBeNull();
expect(await invites.lookup(hashToken(made.token), now)).toBeNull();
const justBefore = new Date(now.getTime() + INVITE_LIFETIME_MS - 1);
expect(await invites.lookup(made.token, justBefore)).toEqual({ id: made.id });
const atExpiry = new Date(now.getTime() + INVITE_LIFETIME_MS);
expect(await invites.lookup(made.token, atExpiry)).toBeNull();
await db.invite.update({ where: { id: made.id }, data: { usedAt: now } });
expect(await invites.lookup(made.token, now)).toBeNull();
});
});
Loading