@vonall/auth 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @vonall/auth
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#17](https://github.com/SebastianvonAllmen/vonall-libs/pull/17) [`f1d99ba`](https://github.com/SebastianvonAllmen/vonall-libs/commit/f1d99ba19b46d4f3a0eb8dd7ed595c9a513b6d0f) Thanks [@SebastianvonAllmen](https://github.com/SebastianvonAllmen)! - An explicit context instead of the registry singleton. `@vonall/auth`: `createAuth(options)` returns `{ options, register }`; `register(auth)` gives `{ ctx, sessionHandle, apiHandler }` and checks the instance's plugins against the schema's options (`admin` is read off the schema, no longer an option). The repositories, services and mails are functions of the context (`userRepo(ctx)`, `einladungRepo(ctx)`, `einladungService(ctx)`, `authMails(ctx)`); remote functions read it with `authContext()`, loads with `contextOf(locals)`; the session handle puts it into `event.locals.auth`. `setAuthUi({ authClient })` takes only the client: the app's name, locale and signup mode come through the page data of the package's loads. `testAuth(db)` returns the context, `createUser(ctx, email)`. `@vonall/mcp`: `createMcp({ auth: ctx, … })`, `mcpPlugins(origin)` replaces `mcpPluginOptions`/`CIMD_OPTIONS`, `agentRepo(ctx)`, `mcpContext()`, `agentsTab()` with its own strings; `ConsentPage` and `AgentsTab` take no `appName`/`locale`; `@vonall/auth` is a peer dependency.
8
+
3
9
  ## 0.5.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -13,16 +13,19 @@ export const { user, session, account, verification, twoFactor, rateLimit, einla
13
13
 
14
14
  // src/lib/auth.ts: the wiring, once; the instance is built here so its types (every plugin's) are inferred here
15
15
  import { ac, roles } from "@vonall/auth";
16
- import { authOptions, registerAuth } from "@vonall/auth/server";
16
+ import { createAuth } from "@vonall/auth/server";
17
17
  import { betterAuth } from "better-auth";
18
18
  import { admin } from "better-auth/plugins";
19
19
  import { twoFactor } from "better-auth/plugins/two-factor";
20
20
  import { sveltekitCookies } from "better-auth/svelte-kit";
21
+ const wiring = createAuth({ db, schema, secret: BETTER_AUTH_SECRET, origin: ORIGIN, appName: APP_NAME, locale: LOCALE, signupMode: SIGNUP_MODE, mailer, social: { github: { clientId, clientSecret } }, log: logger });
21
22
  export const auth = betterAuth({
22
- ...authOptions({ db, schema, secret: BETTER_AUTH_SECRET, origin: ORIGIN, appName: APP_NAME, locale: LOCALE, signupMode: SIGNUP_MODE, mailer, admin: true, social: { github: { clientId, clientSecret } }, log: logger }),
23
+ ...wiring.options,
23
24
  plugins: [twoFactor(), admin({ ac, roles }), /* the MCP endpoint's, from @vonall/mcp */ sveltekitCookies(getRequestEvent)],
24
25
  });
25
- export const { handle: sessionHandle, apiHandler, providers } = registerAuth(auth);
26
+ // The context: everything with accounts outside a request takes it (createMcp, scripts, tests); inside a request it is
27
+ // `event.locals.auth`. register() checks the instance's plugins against the schema's options (admin, mcp).
28
+ export const { ctx, sessionHandle, apiHandler } = wiring.register(auth);
26
29
 
27
30
  // src/lib/auth-client.ts createAuthClient({ baseURL: PUBLIC_ORIGIN, plugins: [twoFactorClient(), adminClient({ ac, roles })] }) (from "better-auth/svelte")
28
31
  // src/hooks.server.ts sequence(securityHeaders, sessionHandle)
@@ -33,10 +36,13 @@ export const { handle: sessionHandle, apiHandler, providers } = registerAuth(aut
33
36
 
34
37
  Entry points: `@vonall/auth` (types, validation schemas, `ac`/`roles` for the admin plugins, `isAdmin`, `mayInvite`, the
35
38
  fresh-session helpers, the messages), `@vonall/auth/schema`, `@vonall/auth/server`, `@vonall/auth/remote` (the remote
36
- functions a Product's components call), `@vonall/auth/testing` (`createUser`, `testAuth` for db tests). `better-auth` is
39
+ functions a Product's components call), `@vonall/auth/testing` (`testAuth(db)` gives the context, `createUser(ctx)` an account, for db tests). `better-auth` is
37
40
  this package's dependency; the Product imports its plugins from the same version. `@node-rs/argon2` (the password
38
41
  hash, a native module) is a peer dependency: the Product lists it, so its server bundle can leave it external.
39
42
 
43
+ The server side is functions of the context: `userRepo(ctx)`, `einladungRepo(ctx)`, `einladungService(ctx)`, `authMails(ctx)`;
44
+ a remote function gets it with `authContext()`, a load with `contextOf(locals)`.
45
+
40
46
  Strings come in English and German: `configureAuthMessages({ locale, overrides })`. A Product's own user-owned tables
41
47
  reference `user.id` with `onDelete: "cascade"`, so deleting a Konto takes everything with it. The MCP endpoint's
42
48
  better-auth plugins and rate limit rule come from `@vonall/mcp` through `plugins` and `rateLimitRules`.
@@ -44,17 +50,18 @@ better-auth plugins and rate limit rule come from `@vonall/mcp` through `plugins
44
50
  ## The pages and the settings (`@vonall/auth/ui`)
45
51
 
46
52
  ```svelte
47
- <!-- src/routes/+layout.svelte: once, so the components know the client, the app and its language -->
53
+ <!-- src/routes/+layout.svelte: once, so the components know the client; the app's name, locale and signup mode
54
+ reach them through the page data of this package's loads (authLayoutLoad, homeLayoutLoad, einladungLoad) -->
48
55
  <script lang="ts">
49
56
  import { setAuthUi } from "@vonall/auth/ui";
50
- setAuthUi({ authClient, appName: APP_NAME, locale: LOCALE, signupMode: SIGNUP_MODE });
57
+ setAuthUi({ authClient });
51
58
  </script>
52
59
 
53
60
  <!-- src/routes/auth/login/+page.svelte --> <LoginForm>{#snippet footer()}…the privacy line…{/snippet}</LoginForm>
54
61
  <!-- src/routes/auth/signup/+page.svelte --> <RegisterForm />
55
62
  <!-- src/routes/auth/reset-password --> <ResetPasswordForm />
56
63
  <!-- src/routes/einladung/[token] --> <EinladungPage {data} tagline={APP_TAGLINE} />
57
- <!-- src/routes/home/settings --> <SettingsPage extraTabs={[{ id: "agents", label, hint, icon: Bot }]}>{#snippet extra(id)}…{/snippet}</SettingsPage>
64
+ <!-- src/routes/home/settings --> <SettingsPage extraTabs={[agentsTab()]}>{#snippet extra(id)}…{/snippet}</SettingsPage>
58
65
  <!-- src/routes/home/manage_user --> <AdminView currentUserId={data.currentUserId} />
59
66
  ```
60
67
 
@@ -3,16 +3,16 @@ import { getRequestEvent } from "$app/server";
3
3
  import { z } from "zod";
4
4
  import { m } from "../messages.js";
5
5
  import { adminQuery, adminSchemaCommand, protectedCommand, protectedForm, protectedQuery, protectedSchemaCommand } from "../server/helpers.js";
6
- import { authInstance } from "../server/registry.js";
7
- import { UserRepo } from "../server/repositories/UserRepo.js";
6
+ import { authContext } from "../server/context.js";
7
+ import { userRepo } from "../server/repositories/UserRepo.js";
8
8
  import { emailSchema, nameSchema, passwordSchema } from "../types.js";
9
9
  import { isSessionNotFresh, reauthPath } from "../util/fresh.js";
10
10
  /** The Admin option: every account. */
11
11
  export const getAllUsers = adminQuery(async () => {
12
- return await UserRepo.getAllUsers();
12
+ return await userRepo(authContext()).getAllUsers();
13
13
  });
14
14
  export const updateUserProfile = protectedForm(z.object({ name: nameSchema, email: emailSchema }), async (data, _issue, user) => {
15
- const auth = authInstance();
15
+ const { auth } = authContext();
16
16
  const event = getRequestEvent();
17
17
  if (data.email !== user.email) {
18
18
  await auth.api.changeEmail({ body: { newEmail: data.email }, headers: event.request.headers });
@@ -24,7 +24,7 @@ const passwordPair = { newPassword: passwordSchema, confirmPassword: z.string()
24
24
  const passwordsMatch = { error: () => m().passwordsMismatch, path: ["confirmPassword"] };
25
25
  export const changePassword = protectedForm(z.object({ currentPassword: passwordSchema, ...passwordPair }).refine((d) => d.newPassword === d.confirmPassword, passwordsMatch), async (data) => {
26
26
  const event = getRequestEvent();
27
- await authInstance().api.changePassword({
27
+ await authContext().auth.api.changePassword({
28
28
  body: { currentPassword: data.currentPassword, newPassword: data.newPassword, revokeOtherSessions: true },
29
29
  headers: event.request.headers,
30
30
  });
@@ -32,13 +32,13 @@ export const changePassword = protectedForm(z.object({ currentPassword: password
32
32
  });
33
33
  export const setPassword = protectedForm(z.object(passwordPair).refine((d) => d.newPassword === d.confirmPassword, passwordsMatch), async (data) => {
34
34
  const event = getRequestEvent();
35
- await authInstance().api.setPassword({ body: { newPassword: data.newPassword }, headers: event.request.headers });
35
+ await authContext().auth.api.setPassword({ body: { newPassword: data.newPassword }, headers: event.request.headers });
36
36
  return { success: true };
37
37
  });
38
38
  export const getSessions = protectedQuery(async () => {
39
39
  const event = getRequestEvent();
40
40
  try {
41
- return await authInstance().api.listSessions({ headers: event.request.headers });
41
+ return await authContext().auth.api.listSessions({ headers: event.request.headers });
42
42
  }
43
43
  catch (e) {
44
44
  // Only a login from the last day may list the sessions; the sessions are in the settings
@@ -50,7 +50,7 @@ export const getSessions = protectedQuery(async () => {
50
50
  export const revokeSessionById = protectedSchemaCommand(z.object({ sessionId: z.string() }), async (data) => {
51
51
  const event = getRequestEvent();
52
52
  const currentSessionToken = event.locals.session?.token;
53
- await authInstance().api.revokeSession({ body: { token: data.sessionId }, headers: event.request.headers });
53
+ await authContext().auth.api.revokeSession({ body: { token: data.sessionId }, headers: event.request.headers });
54
54
  const isCurrentSession = data.sessionId === currentSessionToken;
55
55
  if (!isCurrentSession)
56
56
  await getSessions().refresh();
@@ -58,19 +58,19 @@ export const revokeSessionById = protectedSchemaCommand(z.object({ sessionId: z.
58
58
  });
59
59
  export const revokeOthers = protectedCommand(async () => {
60
60
  const event = getRequestEvent();
61
- await authInstance().api.revokeOtherSessions({ headers: event.request.headers });
61
+ await authContext().auth.api.revokeOtherSessions({ headers: event.request.headers });
62
62
  await getSessions().refresh();
63
63
  return { success: true };
64
64
  });
65
65
  export const getLinkedAccounts = protectedQuery(async (user) => {
66
- return await UserRepo.getUserAccounts(user.id);
66
+ return await userRepo(authContext()).getUserAccounts(user.id);
67
67
  });
68
68
  /** The Admin option. */
69
69
  export const setUserRole = adminSchemaCommand(z.object({ userId: z.string(), role: z.enum(["admin", "user"]) }), async ({ userId, role }, admin) => {
70
70
  if (userId === admin.id) {
71
71
  throw error(400, { message: m().ownRole, id: "own_role", timestamp: new Date().toISOString() });
72
72
  }
73
- await UserRepo.setRole(userId, role);
73
+ await userRepo(authContext()).setRole(userId, role);
74
74
  await getAllUsers().refresh();
75
75
  return { success: true };
76
76
  });
@@ -79,12 +79,12 @@ export const deleteUser = adminSchemaCommand(z.object({ userId: z.string() }), a
79
79
  if (userId === admin.id) {
80
80
  throw error(400, { message: m().ownAccount, id: "own_account", timestamp: new Date().toISOString() });
81
81
  }
82
- await UserRepo.kontoLoeschen(userId);
82
+ await userRepo(authContext()).kontoLoeschen(userId);
83
83
  await getAllUsers().refresh();
84
84
  return { success: true };
85
85
  });
86
86
  /** Deletes the user's own account with all its data. */
87
87
  export const deleteOwnAccount = protectedCommand(async (user) => {
88
- await UserRepo.kontoLoeschen(user.id);
88
+ await userRepo(authContext()).kontoLoeschen(user.id);
89
89
  return { success: true };
90
90
  });
@@ -2,10 +2,10 @@ import { error } from "@sveltejs/kit";
2
2
  import { z } from "zod";
3
3
  import { m } from "../messages.js";
4
4
  import { adminQuery, adminSchemaCommand, protectedQuery, protectedSchemaCommand } from "../server/helpers.js";
5
- import { registry } from "../server/registry.js";
6
- import { EinladungRepo } from "../server/repositories/EinladungRepo.js";
7
- import { EinladungService } from "../server/service/einladung.service.js";
8
- import { sendEinladungEmail } from "../server/service/mail.js";
5
+ import { authContext } from "../server/context.js";
6
+ import { einladungRepo } from "../server/repositories/EinladungRepo.js";
7
+ import { einladungService } from "../server/service/einladung.service.js";
8
+ import { authMails } from "../server/service/mail.js";
9
9
  import { emailSchema } from "../types.js";
10
10
  import { messageOf } from "../util/messageOf.js";
11
11
  import { isAdmin, mayInvite } from "../util/signup.js";
@@ -16,20 +16,22 @@ function unwrap(result) {
16
16
  }
17
17
  /** The user's open Einladungen, and whether the server can send them by email. */
18
18
  export const getOffeneEinladungen = protectedQuery(async (user) => {
19
- return { einladungen: await EinladungRepo.listOpenBy(user.id, new Date()), mail: registry().mailer.enabled };
19
+ const ctx = authContext();
20
+ return { einladungen: await einladungRepo(ctx).listOpenBy(user.id, new Date()), mail: ctx.mailer.enabled };
20
21
  });
21
22
  /** Creates an Einladung; the link is returned only here. With an email, it is also sent (if mail is set up). */
22
23
  export const createEinladung = protectedSchemaCommand(z.object({ email: emailSchema.nullable() }), async ({ email }, user) => {
23
- const { origin, signupMode, admin, locale } = registry();
24
+ const ctx = authContext();
25
+ const { origin, signupMode, admin } = ctx;
24
26
  if (!mayInvite(signupMode, isAdmin(user))) {
25
27
  throw error(403, { message: m().onlyAdminsInvite, id: "forbidden", timestamp: new Date().toISOString() });
26
28
  }
27
- const einladung = await EinladungService.erstellen(user.id, { email });
29
+ const einladung = await einladungService(ctx).erstellen(user.id, { email });
28
30
  const link = `${origin}/einladung/${einladung.token}`;
29
31
  let mailFehler = null;
30
32
  if (email) {
31
33
  try {
32
- await sendEinladungEmail(user.name, email, { link, expiresAt: einladung.expiresAt }, locale);
34
+ await authMails(ctx).einladung(user.name, email, { link, expiresAt: einladung.expiresAt });
33
35
  }
34
36
  catch (e) {
35
37
  mailFehler = messageOf(e, m().mailFailed);
@@ -41,17 +43,18 @@ export const createEinladung = protectedSchemaCommand(z.object({ email: emailSch
41
43
  return { link, expiresAt: einladung.expiresAt, mailFehler };
42
44
  });
43
45
  export const withdrawEinladung = protectedSchemaCommand(z.object({ einladungId: z.string() }), async ({ einladungId }, user) => {
44
- unwrap(await EinladungService.zurueckziehen(user.id, einladungId));
46
+ const ctx = authContext();
47
+ unwrap(await einladungService(ctx).zurueckziehen(user.id, einladungId));
45
48
  await getOffeneEinladungen().refresh();
46
- if (registry().admin && isAdmin(user))
49
+ if (ctx.admin && isAdmin(user))
47
50
  await getAlleEinladungen().refresh();
48
51
  });
49
52
  /** The Admin option: every open Einladung, and withdrawing any of them. */
50
53
  export const getAlleEinladungen = adminQuery(async () => {
51
- return await EinladungRepo.listAllOpen(new Date());
54
+ return await einladungRepo(authContext()).listAllOpen(new Date());
52
55
  });
53
56
  export const adminWithdrawEinladung = adminSchemaCommand(z.object({ einladungId: z.string() }), async ({ einladungId }) => {
54
- unwrap(await EinladungService.zurueckziehen(null, einladungId));
57
+ unwrap(await einladungService(authContext()).zurueckziehen(null, einladungId));
55
58
  await getAlleEinladungen().refresh();
56
59
  await getOffeneEinladungen().refresh();
57
60
  });
@@ -1,11 +1,6 @@
1
- /**
2
- * What `createAuth()` set up, for the package's repositories, services and remote functions: the Product's database,
3
- * its tables, the options and the better-auth instance. One app per process, so a module-level value; the Product's
4
- * wiring (`src/lib/auth.ts`) runs before any request.
5
- */
6
1
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
7
2
  import type { Mailer } from "@vonall/mail";
8
- import type { FullSchema } from "../schema/index.js";
3
+ import type { AuthSchema } from "../schema/index.js";
9
4
  import type { Auth } from "./create.js";
10
5
  import type { SignupMode } from "../types.js";
11
6
  export type Db = NodePgDatabase<any>;
@@ -16,27 +11,29 @@ export type AuthLog = {
16
11
  warn(message: string, context?: Record<string, unknown>): void;
17
12
  error(message: string, context?: Record<string, unknown>, error?: unknown): void;
18
13
  };
19
- export type Registry = {
14
+ export type AuthContext<S extends AuthSchema = AuthSchema> = {
20
15
  db: Db;
21
- schema: FullSchema;
16
+ schema: S;
22
17
  appName: string;
23
18
  origin: string;
24
- /** The app's locale for dates in mails, e.g. "de-CH". */
19
+ /** The app's locale, e.g. "de-CH": dates in mails and on the pages. */
25
20
  locale: string;
26
21
  signupMode: SignupMode;
27
22
  mailer: Mailer;
28
- admin: boolean;
29
23
  log: AuthLog;
30
- /** The Product's instance, from `registerAuth()`; null between `authOptions()` and that call. */
31
- auth: Auth | null;
24
+ /** The Admin option: `schema` has the role columns (and the instance the admin plugin, `register()` checks). */
25
+ admin: boolean;
26
+ /** Whether `schema` has the OAuth provider's tables (`@vonall/mcp`). */
27
+ mcp: boolean;
32
28
  /** The social logins that have keys. */
33
29
  providers: {
34
30
  github: boolean;
35
31
  google: boolean;
36
32
  };
33
+ /** The Product's instance; reading it before `register()` throws. */
34
+ readonly auth: Auth;
37
35
  };
38
- export declare function setRegistry(registry: Registry): void;
39
- export declare function registry(): Registry;
40
- /** The Product's better-auth instance. */
41
- export declare function authInstance(): Auth;
36
+ /** The context of the current request: `event.locals.auth`, which the session handle set. */
37
+ export declare function authContext(): AuthContext;
38
+ export declare function contextOf(locals: object): AuthContext;
42
39
  export declare const consoleLog: AuthLog;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * What `createAuth()` set up, for everything else in this package and in `@vonall/mcp`: the Product's database and
3
+ * tables, its options and, after `register()`, its better-auth instance. It is passed explicitly; inside a request it
4
+ * is also in `event.locals.auth` (the session handle puts it there), where the remote functions and the loads find it.
5
+ */
6
+ import { getRequestEvent } from "$app/server";
7
+ /** The context of the current request: `event.locals.auth`, which the session handle set. */
8
+ export function authContext() {
9
+ return contextOf(getRequestEvent().locals);
10
+ }
11
+ export function contextOf(locals) {
12
+ const ctx = locals.auth;
13
+ if (!ctx)
14
+ throw new Error("@vonall/auth: no auth context on this request; the session handle of createAuth().register() is not in hooks.server.ts");
15
+ return ctx;
16
+ }
17
+ export const consoleLog = {
18
+ debug: () => { },
19
+ info: (message, context) => console.info(message, context ?? ""),
20
+ warn: (message, context) => console.warn(message, context ?? ""),
21
+ error: (message, context, error) => console.error(message, context ?? "", error ?? ""),
22
+ };
@@ -1,7 +1,8 @@
1
1
  /**
2
- * better-auth for a Product, from explicit options. The Product's `src/lib/auth.ts` builds the instance itself, so its
3
- * types are inferred in the Product (its plugins included): `betterAuth({ ...authOptions({…}), plugins: […] })`, then
4
- * `registerAuth(auth)` for the session handle and the API handler; everything else in this package reads the result.
2
+ * better-auth for a Product: `const wiring = createAuth({…})`, then the Product builds the instance itself, so its
3
+ * types are inferred there (its plugins included): `betterAuth({ ...wiring.options, plugins: […] })`, and
4
+ * `wiring.register(auth)` gives the context, the session handle and the API handler; everything else in this package
5
+ * takes the context.
5
6
  */
6
7
  import type { RequestHandler } from "@sveltejs/kit";
7
8
  import type { Handle } from "@sveltejs/kit/hooks";
@@ -12,29 +13,27 @@ import { sveltekitCookies } from "better-auth/svelte-kit";
12
13
  import type { Mailer } from "@vonall/mail";
13
14
  import { roles, ac } from "../permissions.js";
14
15
  import type { AuthSchema } from "../schema/index.js";
15
- import { type AuthLog, type Db } from "./registry.js";
16
+ import { type AuthContext, type AuthLog, type Db } from "./context.js";
16
17
  import { type SignupMode } from "../types.js";
17
18
  export type SocialProvider = {
18
19
  clientId: string;
19
20
  clientSecret: string;
20
21
  };
21
- export type CreateAuthOptions = {
22
+ export type CreateAuthOptions<S extends AuthSchema = AuthSchema> = {
22
23
  /** The Product's Drizzle database. */
23
24
  db: Db;
24
- /** The Product's tables from `authSchema()`, the same objects its `schema/` re-exports. */
25
- schema: AuthSchema;
25
+ /** The Product's tables from `authSchema()`, the same objects its `schema/` re-exports; its options are read off them. */
26
+ schema: S;
26
27
  /** BETTER_AUTH_SECRET. */
27
28
  secret: string;
28
29
  /** The app's own address (ORIGIN), e.g. https://blueprint.vonall.ch. */
29
30
  origin: string;
30
31
  appName: string;
31
- /** LOCALE, e.g. "en-GB" or "de-CH": dates in mails. */
32
+ /** LOCALE, e.g. "en-GB" or "de-CH": dates in mails and on the pages. */
32
33
  locale: string;
33
34
  signupMode: SignupMode;
34
35
  /** The Product's mailer (`@vonall/mail`); off without SMTP, which the signup mode `open` and the reset respect. */
35
36
  mailer: Mailer;
36
- /** The Admin option: the admin plugin and roles, the first account becomes admin. Matches `authSchema({ admin })`. */
37
- admin?: boolean;
38
37
  /** Social logins with keys; a provider without keys is left out (its button never shows). */
39
38
  social?: {
40
39
  github?: SocialProvider | null;
@@ -60,13 +59,18 @@ export type Auth<P extends BetterAuthPlugin[] = []> = ReturnType<typeof betterAu
60
59
  plugins: [...BasePlugins, ...P];
61
60
  }>>;
62
61
  /**
63
- * The Product's instance as `registerAuth()` takes it: built on `authOptions()`, with any plugins (their types stay the
62
+ * The Product's instance as `register()` takes it: built on `wiring.options`, with any plugins (their types stay the
64
63
  * Product's; this package uses the instance through its own `Auth` type).
65
64
  */
66
65
  export type AuthLike = {
67
66
  handler: (request: Request) => Promise<Response>;
68
67
  api: object;
69
68
  $context: Promise<object>;
69
+ options: {
70
+ plugins?: {
71
+ id: string;
72
+ }[];
73
+ };
70
74
  };
71
75
  /** The admin plugin's columns, named here because the plugin's type doesn't carry them into the session's user. */
72
76
  export type AdminFields = {
@@ -78,18 +82,19 @@ export type AdminFields = {
78
82
  /** The user and the session as the hooks put them into `locals` (a Product's `app.d.ts` uses these). */
79
83
  export type AuthUser = Auth["$Infer"]["Session"]["user"] & AdminFields;
80
84
  export type AuthSession = Auth["$Infer"]["Session"]["session"];
81
- export declare function authOptions(options: CreateAuthOptions): BetterAuthOptions;
82
- export type RegisteredAuth = {
83
- /** The hooks handle: the session into `locals.user`/`locals.session`, and better-auth's own routes (/api/auth/*). */
84
- handle: Handle;
85
+ export type RegisteredAuth<S extends AuthSchema = AuthSchema> = {
86
+ /** For everything that works with accounts outside a request: `createMcp`, scripts, tests. */
87
+ ctx: AuthContext<S>;
88
+ /** The hooks handle: the context into `locals.auth`, the session into `locals.user`/`locals.session`, and better-auth's own routes (/api/auth/*). */
89
+ sessionHandle: Handle;
85
90
  /** For `src/routes/api/auth/[...auth]/+server.ts`: `export const GET = apiHandler, POST = apiHandler`. */
86
91
  apiHandler: RequestHandler;
87
- /** The social logins that have keys; the login, signup and settings pages show only these. */
88
- providers: {
89
- github: boolean;
90
- google: boolean;
91
- };
92
92
  };
93
- /** Hands the Product's instance to this package (after `authOptions()`) and returns the handles. */
94
- export declare function registerAuth(auth: AuthLike): RegisteredAuth;
93
+ export type AuthWiring<S extends AuthSchema = AuthSchema> = {
94
+ /** For `betterAuth({ ...options, plugins: […] })`. */
95
+ options: BetterAuthOptions;
96
+ /** Hands the Product's instance to this package; checks its plugins against the schema's options. */
97
+ register(auth: AuthLike): RegisteredAuth<S>;
98
+ };
99
+ export declare function createAuth<S extends AuthSchema>(options: CreateAuthOptions<S>): AuthWiring<S>;
95
100
  export {};
@@ -7,11 +7,11 @@ import { twoFactor } from "better-auth/plugins/two-factor";
7
7
  import { sveltekitCookies, svelteKitHandler } from "better-auth/svelte-kit";
8
8
  import { m } from "../messages.js";
9
9
  import { roles, ac } from "../permissions.js";
10
+ import { consoleLog } from "./context.js";
10
11
  import { hashPassword, verifyPassword } from "./crypto.js";
11
- import { consoleLog, registry, setRegistry } from "./registry.js";
12
- import { UserRepo } from "./repositories/UserRepo.js";
13
- import { EINLADUNG_COOKIE, EinladungService } from "./service/einladung.service.js";
14
- import { sendPasswordResetEmail, sendVerifyEmail } from "./service/mail.js";
12
+ import { userRepo } from "./repositories/UserRepo.js";
13
+ import { EINLADUNG_COOKIE, einladungService } from "./service/einladung.service.js";
14
+ import { authMails } from "./service/mail.js";
15
15
  import { MIN_PASSWORD_LENGTH } from "../types.js";
16
16
  import { nameFromEmail } from "../util/names.js";
17
17
  /** The Einladung token the browser carries from its link (`/einladung/[token]`), if any. */
@@ -24,9 +24,10 @@ function einladungAusCookie() {
24
24
  }
25
25
  }
26
26
  const hasKeys = (p) => Boolean(p?.clientId && p.clientSecret && p.clientId !== "dummy");
27
- export function authOptions(options) {
28
- const { secret, origin, appName, signupMode, admin = false, social = {}, rateLimitRules = {}, log = consoleLog } = options;
29
- setRegistry({
27
+ export function createAuth(options) {
28
+ const { secret, origin, appName, signupMode, social = {}, rateLimitRules = {}, log = consoleLog } = options;
29
+ let instance = null;
30
+ const ctx = {
30
31
  db: options.db,
31
32
  schema: options.schema,
32
33
  appName,
@@ -34,13 +35,21 @@ export function authOptions(options) {
34
35
  locale: options.locale,
35
36
  signupMode,
36
37
  mailer: options.mailer,
37
- admin,
38
38
  log,
39
- auth: null,
39
+ admin: "role" in options.schema.user,
40
+ mcp: "oauthClient" in options.schema,
40
41
  providers: { github: hasKeys(social.github), google: hasKeys(social.google) },
41
- });
42
+ get auth() {
43
+ if (!instance)
44
+ throw new Error("@vonall/auth: createAuth().register(auth) has not run; the app's auth wiring (src/lib/auth.ts) calls it after betterAuth()");
45
+ return instance;
46
+ },
47
+ };
48
+ const users = userRepo(ctx);
49
+ const einladungen = einladungService(ctx);
50
+ const mails = authMails(ctx);
42
51
  const baseURL = origin || "http://localhost";
43
- return {
52
+ const betterAuthOptions = {
44
53
  appName,
45
54
  baseURL,
46
55
  secret: secret || "dummy-secret-for-build-purposes-only",
@@ -73,7 +82,7 @@ export function authOptions(options) {
73
82
  before: async (user) => {
74
83
  // The signup mode decides: an Einladung, or (open) mail for the confirmation link. The very first account
75
84
  // (the admin) needs neither and counts as confirmed.
76
- const { state, firstAccount } = await EinladungService.signup(einladungAusCookie());
85
+ const { state, firstAccount } = await einladungen.signup(einladungAusCookie());
77
86
  if (state === "needs_einladung")
78
87
  throw new APIError("FORBIDDEN", { message: m().needsEinladung });
79
88
  if (state === "no_mail")
@@ -87,14 +96,14 @@ export function authOptions(options) {
87
96
  },
88
97
  after: async (user) => {
89
98
  // The first user becomes admin
90
- if (admin && (await UserRepo.countUsers()) === 1) {
99
+ if (ctx.admin && (await users.countUsers()) === 1) {
91
100
  log.info("First user created, bootstrapping global role to admin");
92
- await UserRepo.setRole(user.id, "admin");
101
+ await users.setRole(user.id, "admin");
93
102
  }
94
103
  const token = einladungAusCookie();
95
104
  if (!token)
96
105
  return;
97
- const angenommen = await EinladungService.annehmen(user.id, token);
106
+ const angenommen = await einladungen.annehmen(user.id, token);
98
107
  if (angenommen.isErr())
99
108
  log.warn("Einladung could not be accepted after signup", { userId: user.id, kind: angenommen.error.kind });
100
109
  getRequestEvent().cookies.delete(EINLADUNG_COOKIE, { path: "/" });
@@ -109,7 +118,7 @@ export function authOptions(options) {
109
118
  // Signup mode open: anyone may sign up, so the email address is confirmed before the account can be used
110
119
  requireEmailVerification: signupMode === "open",
111
120
  sendResetPassword: async ({ user, url }) => {
112
- await sendPasswordResetEmail(user, url);
121
+ await mails.passwordReset(user, url);
113
122
  },
114
123
  },
115
124
  emailVerification: {
@@ -118,7 +127,7 @@ export function authOptions(options) {
118
127
  autoSignInAfterVerification: true,
119
128
  sendVerificationEmail: async ({ user, url }) => {
120
129
  if (!user.emailVerified)
121
- await sendVerifyEmail(user, url);
130
+ await mails.verify(user, url);
122
131
  },
123
132
  },
124
133
  socialProviders: {
@@ -134,21 +143,29 @@ export function authOptions(options) {
134
143
  cookieCache: { enabled: false },
135
144
  },
136
145
  };
137
- }
138
- /** Hands the Product's instance to this package (after `authOptions()`) and returns the handles. */
139
- export function registerAuth(auth) {
140
- const current = registry();
141
- setRegistry({ ...current, auth: auth });
142
- const instance = auth;
143
- const handle = async ({ event, resolve }) => {
144
- const session = await instance.api.getSession({ headers: event.request.headers });
145
- if (session) {
146
- event.locals.session = session.session;
147
- event.locals.user = session.user;
148
- }
149
- const { building } = await import("$app/env");
150
- return svelteKitHandler({ event, resolve, auth: instance, building });
146
+ return {
147
+ options: betterAuthOptions,
148
+ register(auth) {
149
+ // The instance's plugins must match the schema's options, or the columns and the plugin disagree at runtime
150
+ const plugins = new Set((auth.options.plugins ?? []).map((p) => p.id));
151
+ if (plugins.has("admin") !== ctx.admin) {
152
+ throw new Error(`@vonall/auth: ${ctx.admin ? "authSchema({ admin: true }) but the instance has no admin plugin" : "the instance has the admin plugin but authSchema() was built without admin"}`);
153
+ }
154
+ if (plugins.has("oauth-provider") && !ctx.mcp)
155
+ throw new Error("@vonall/auth: the instance has the MCP plugin but authSchema() was built without mcp (its tables are missing)");
156
+ instance = auth;
157
+ const sessionHandle = async ({ event, resolve }) => {
158
+ event.locals.auth = ctx;
159
+ const session = await ctx.auth.api.getSession({ headers: event.request.headers });
160
+ if (session) {
161
+ event.locals.session = session.session;
162
+ event.locals.user = session.user;
163
+ }
164
+ const { building } = await import("$app/env");
165
+ return svelteKitHandler({ event, resolve, auth: ctx.auth, building });
166
+ };
167
+ const apiHandler = ({ request }) => auth.handler(request);
168
+ return { ctx, sessionHandle, apiHandler };
169
+ },
151
170
  };
152
- const apiHandler = ({ request }) => auth.handler(request);
153
- return { handle, apiHandler, providers: current.providers };
154
171
  }
@@ -1,8 +1,8 @@
1
- export { authOptions, registerAuth, type AdminFields, type Auth, type AuthLike, type AuthSession, type AuthUser, type CreateAuthOptions, type RegisteredAuth, type SocialProvider } from "./create.js";
1
+ export { authContext, contextOf, type AuthContext, type AuthLog, type Db } from "./context.js";
2
+ export { createAuth, type AdminFields, type Auth, type AuthLike, type AuthSession, type AuthUser, type AuthWiring, type CreateAuthOptions, type RegisteredAuth, type SocialProvider } from "./create.js";
2
3
  export { adminCommand, adminQuery, adminSchemaCommand, adminSchemaQuery, protectedCommand, protectedForm, protectedQuery, protectedSchemaCommand, protectedSchemaQuery, requireAdmin, requireUser, } from "./helpers.js";
3
- export { authLayoutLoad, einladungLoad, homeLayoutLoad, loginLoad, manageUserLoad, signupLoad } from "./loads.js";
4
- export { authInstance, registry as authRegistry, type AuthLog, type Db } from "./registry.js";
5
- export { EinladungRepo } from "./repositories/EinladungRepo.js";
6
- export { UserRepo } from "./repositories/UserRepo.js";
7
- export { EINLADUNG_COOKIE, EINLADUNG_GUELTIG_TAGE, EinladungService, type EinladungError } from "./service/einladung.service.js";
8
- export { sendEinladungEmail, sendPasswordResetEmail, sendVerifyEmail } from "./service/mail.js";
4
+ export { authLayoutLoad, einladungLoad, homeLayoutLoad, loginLoad, manageUserLoad, signupLoad, type AuthUiData } from "./loads.js";
5
+ export { einladungRepo, type EinladungRepo } from "./repositories/EinladungRepo.js";
6
+ export { userRepo, type UserRepo } from "./repositories/UserRepo.js";
7
+ export { EINLADUNG_COOKIE, EINLADUNG_GUELTIG_TAGE, einladungService, type EinladungError, type EinladungService } from "./service/einladung.service.js";
8
+ export { authMails, type AuthMails } from "./service/mail.js";
@@ -1,8 +1,8 @@
1
- export { authOptions, registerAuth } from "./create.js";
1
+ export { authContext, contextOf } from "./context.js";
2
+ export { createAuth } from "./create.js";
2
3
  export { adminCommand, adminQuery, adminSchemaCommand, adminSchemaQuery, protectedCommand, protectedForm, protectedQuery, protectedSchemaCommand, protectedSchemaQuery, requireAdmin, requireUser, } from "./helpers.js";
3
4
  export { authLayoutLoad, einladungLoad, homeLayoutLoad, loginLoad, manageUserLoad, signupLoad } from "./loads.js";
4
- export { authInstance, registry as authRegistry } from "./registry.js";
5
- export { EinladungRepo } from "./repositories/EinladungRepo.js";
6
- export { UserRepo } from "./repositories/UserRepo.js";
7
- export { EINLADUNG_COOKIE, EINLADUNG_GUELTIG_TAGE, EinladungService } from "./service/einladung.service.js";
8
- export { sendEinladungEmail, sendPasswordResetEmail, sendVerifyEmail } from "./service/mail.js";
5
+ export { einladungRepo } from "./repositories/EinladungRepo.js";
6
+ export { userRepo } from "./repositories/UserRepo.js";
7
+ export { EINLADUNG_COOKIE, EINLADUNG_GUELTIG_TAGE, einladungService } from "./service/einladung.service.js";
8
+ export { authMails } from "./service/mail.js";