@vonall/auth 0.0.0-stage → 0.1.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +33 -2
  4. package/dist/client/index.d.ts +17 -0
  5. package/dist/client/index.js +18 -0
  6. package/dist/index.d.ts +8 -0
  7. package/dist/index.js +10 -0
  8. package/dist/messages.d.ts +50 -0
  9. package/dist/messages.js +88 -0
  10. package/dist/permissions.d.ts +48 -0
  11. package/dist/permissions.js +17 -0
  12. package/dist/remote/app_user.remote.d.ts +66 -0
  13. package/dist/remote/app_user.remote.js +91 -0
  14. package/dist/remote/einladung.remote.d.ts +27 -0
  15. package/dist/remote/einladung.remote.js +56 -0
  16. package/dist/remote/index.d.ts +2 -0
  17. package/dist/remote/index.js +2 -0
  18. package/dist/schema/index.d.ts +3683 -0
  19. package/dist/schema/index.js +265 -0
  20. package/dist/server/create.d.ts +87 -0
  21. package/dist/server/create.js +158 -0
  22. package/dist/server/crypto.d.ts +5 -0
  23. package/dist/server/crypto.js +15 -0
  24. package/dist/server/helpers.d.ts +18 -0
  25. package/dist/server/helpers.js +62 -0
  26. package/dist/server/index.d.ts +9 -0
  27. package/dist/server/index.js +9 -0
  28. package/dist/server/loads.d.ts +57 -0
  29. package/dist/server/loads.js +83 -0
  30. package/dist/server/registry.d.ts +39 -0
  31. package/dist/server/registry.js +15 -0
  32. package/dist/server/repositories/EinladungRepo.d.ts +36 -0
  33. package/dist/server/repositories/EinladungRepo.js +74 -0
  34. package/dist/server/repositories/UserRepo.d.ts +19 -0
  35. package/dist/server/repositories/UserRepo.js +67 -0
  36. package/dist/server/service/einladung.service.d.ts +48 -0
  37. package/dist/server/service/einladung.service.js +76 -0
  38. package/dist/server/service/konto.service.d.ts +7 -0
  39. package/dist/server/service/konto.service.js +11 -0
  40. package/dist/server/service/mail.d.ts +12 -0
  41. package/dist/server/service/mail.js +40 -0
  42. package/dist/templates/InvitationEmail.svelte +18 -0
  43. package/dist/templates/InvitationEmail.svelte.d.ts +10 -0
  44. package/dist/templates/ResetPasswordEmail.svelte +16 -0
  45. package/dist/templates/ResetPasswordEmail.svelte.d.ts +9 -0
  46. package/dist/templates/VerifyEmailEmail.svelte +16 -0
  47. package/dist/templates/VerifyEmailEmail.svelte.d.ts +9 -0
  48. package/dist/testing/index.d.ts +6 -0
  49. package/dist/testing/index.js +26 -0
  50. package/dist/types.d.ts +38 -0
  51. package/dist/types.js +21 -0
  52. package/dist/util/fresh.d.ts +11 -0
  53. package/dist/util/fresh.js +20 -0
  54. package/dist/util/messageOf.d.ts +5 -0
  55. package/dist/util/messageOf.js +12 -0
  56. package/dist/util/names.d.ts +10 -0
  57. package/dist/util/names.js +19 -0
  58. package/dist/util/sameSite.d.ts +5 -0
  59. package/dist/util/sameSite.js +15 -0
  60. package/dist/util/signup.d.ts +15 -0
  61. package/dist/util/signup.js +23 -0
  62. package/package.json +96 -3
@@ -0,0 +1,265 @@
1
+ /**
2
+ * The tables of accounts: better-auth's (user, session, account, verification, twoFactor, rate_limit) and the
3
+ * Einladung; with `admin` the role and ban columns, with `mcp` the OAuth provider's tables (`jwks`, `oauth_*`) the
4
+ * `@vonall/mcp` package uses. A Product builds them once in its `src/lib/remote/schema/auth.sql.ts`, re-exports them so
5
+ * drizzle-kit sees them, and generates its own migrations (`schema:` releases of this package say when).
6
+ */
7
+ import { bigint, boolean, index, integer, jsonb, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
8
+ const userColumns = () => ({
9
+ id: text("id").primaryKey(),
10
+ name: text("name").notNull(),
11
+ email: text("email").notNull().unique(),
12
+ emailVerified: boolean("email_verified").notNull(),
13
+ image: text("image"),
14
+ createdAt: timestamp("created_at").notNull(),
15
+ updatedAt: timestamp("updated_at").notNull(),
16
+ twoFactorEnabled: boolean("two_factor_enabled"),
17
+ });
18
+ const adminColumns = () => ({
19
+ role: text("role").notNull().default("user"),
20
+ banned: boolean("banned").notNull().default(false),
21
+ banReason: text("ban_reason"),
22
+ banExpires: timestamp("ban_expires", { precision: 6, withTimezone: true }),
23
+ });
24
+ const buildUser = () => pgTable("user", userColumns());
25
+ const buildUserAdmin = () => pgTable("user", { ...userColumns(), ...adminColumns() });
26
+ const sessionColumns = (user) => ({
27
+ id: text("id").primaryKey(),
28
+ expiresAt: timestamp("expires_at").notNull(),
29
+ token: text("token").notNull().unique(),
30
+ createdAt: timestamp("created_at").notNull(),
31
+ updatedAt: timestamp("updated_at").notNull(),
32
+ ipAddress: text("ip_address"),
33
+ userAgent: text("user_agent"),
34
+ userId: text("user_id")
35
+ .notNull()
36
+ .references(() => user.id, { onDelete: "cascade" }),
37
+ });
38
+ const buildSession = (user) => pgTable("session", sessionColumns(user));
39
+ const buildSessionAdmin = (user) => pgTable("session", { ...sessionColumns(user), impersonatedBy: text("impersonated_by") });
40
+ function buildShared(user) {
41
+ const account = pgTable("account", {
42
+ id: text("id").primaryKey(),
43
+ accountId: text("account_id").notNull(),
44
+ providerId: text("provider_id").notNull(),
45
+ userId: text("user_id")
46
+ .notNull()
47
+ .references(() => user.id, { onDelete: "cascade" }),
48
+ accessToken: text("access_token"),
49
+ refreshToken: text("refresh_token"),
50
+ idToken: text("id_token"),
51
+ accessTokenExpiresAt: timestamp("access_token_expires_at"),
52
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
53
+ scope: text("scope"),
54
+ password: text("password"),
55
+ createdAt: timestamp("created_at").notNull(),
56
+ updatedAt: timestamp("updated_at").notNull(),
57
+ });
58
+ const verification = pgTable("verification", {
59
+ id: text("id").primaryKey(),
60
+ identifier: text("identifier").notNull(),
61
+ value: text("value").notNull(),
62
+ expiresAt: timestamp("expires_at").notNull(),
63
+ createdAt: timestamp("created_at"),
64
+ updatedAt: timestamp("updated_at"),
65
+ });
66
+ const twoFactor = pgTable("twoFactor", {
67
+ id: text("id").primaryKey(),
68
+ userId: text("user_id")
69
+ .notNull()
70
+ .references(() => user.id, { onDelete: "cascade" }),
71
+ secret: text("secret").notNull(),
72
+ backupCodes: text("backup_codes").notNull(),
73
+ verified: boolean("verified").notNull().default(true),
74
+ failedVerificationCount: integer("failed_verification_count").notNull().default(0),
75
+ lockedUntil: timestamp("locked_until"),
76
+ });
77
+ /** better-auth rate limit counters, kept in Postgres so they survive restarts. */
78
+ const rateLimit = pgTable("rate_limit", {
79
+ id: text("id").primaryKey(),
80
+ key: text("key").notNull().unique(),
81
+ count: integer("count").notNull(),
82
+ lastRequest: bigint("last_request", { mode: "number" }).notNull(),
83
+ });
84
+ /**
85
+ * An Einladung into the app: signup is invite only once the first account (the admin) exists. A link works once,
86
+ * until it expires; only the SHA-256 hash of its token is stored, the link is shown once when it is created.
87
+ */
88
+ const einladung = pgTable("einladung", {
89
+ id: text("id").primaryKey(),
90
+ tokenHash: text("token_hash").notNull().unique(),
91
+ createdBy: text("created_by")
92
+ .notNull()
93
+ .references(() => user.id, { onDelete: "cascade" }),
94
+ /** Set when the Einladung was sent by email, so the inviter recognises it in the list. */
95
+ email: text("email"),
96
+ expiresAt: timestamp("expires_at", { precision: 6, withTimezone: true }).notNull(),
97
+ acceptedAt: timestamp("accepted_at", { precision: 6, withTimezone: true }),
98
+ acceptedBy: text("accepted_by").references(() => user.id, { onDelete: "set null" }),
99
+ createdAt: timestamp("created_at", { precision: 6, withTimezone: true }).notNull().defaultNow(),
100
+ });
101
+ return { account, verification, twoFactor, rateLimit, einladung };
102
+ }
103
+ function buildMcp(user, session) {
104
+ /** Signing keys of the OAuth provider (better-auth `jwt` plugin); MCP access tokens are JWTs signed with them. */
105
+ const jwks = pgTable("jwks", {
106
+ id: text("id").primaryKey(),
107
+ publicKey: text("public_key").notNull(),
108
+ privateKey: text("private_key").notNull(),
109
+ createdAt: timestamp("created_at").notNull(),
110
+ expiresAt: timestamp("expires_at"),
111
+ alg: text("alg"),
112
+ crv: text("crv"),
113
+ });
114
+ /** OAuth clients of the MCP endpoint: AI assistants, registered dynamically or through a Client ID Metadata Document. */
115
+ const oauthClient = pgTable("oauth_client", {
116
+ id: text("id").primaryKey(),
117
+ clientId: text("client_id").notNull().unique(),
118
+ clientSecret: text("client_secret"),
119
+ clientDiscoveryId: text("client_discovery_id"),
120
+ disabled: boolean("disabled").default(false),
121
+ skipConsent: boolean("skip_consent"),
122
+ enableEndSession: boolean("enable_end_session"),
123
+ subjectType: text("subject_type"),
124
+ scopes: text("scopes").array(),
125
+ clientCredentialsScopes: text("client_credentials_scopes").array().default([]),
126
+ userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
127
+ createdAt: timestamp("created_at"),
128
+ updatedAt: timestamp("updated_at"),
129
+ name: text("name"),
130
+ uri: text("uri"),
131
+ icon: text("icon"),
132
+ contacts: text("contacts").array(),
133
+ tos: text("tos"),
134
+ policy: text("policy"),
135
+ softwareId: text("software_id"),
136
+ softwareVersion: text("software_version"),
137
+ softwareStatement: text("software_statement"),
138
+ redirectUris: text("redirect_uris").array().notNull(),
139
+ postLogoutRedirectUris: text("post_logout_redirect_uris").array(),
140
+ backchannelLogoutUri: text("backchannel_logout_uri"),
141
+ backchannelLogoutSessionRequired: boolean("backchannel_logout_session_required"),
142
+ tokenEndpointAuthMethod: text("token_endpoint_auth_method"),
143
+ applicationType: text("application_type"),
144
+ jwks: text("jwks"),
145
+ jwksUri: text("jwks_uri"),
146
+ grantTypes: text("grant_types").array(),
147
+ responseTypes: text("response_types").array(),
148
+ requirePKCE: boolean("require_pkce"),
149
+ dpopBoundAccessTokens: boolean("dpop_bound_access_tokens").default(false),
150
+ referenceId: text("reference_id"),
151
+ metadata: jsonb("metadata"),
152
+ }, (table) => [index("oauthClient_userId_idx").on(table.userId)]);
153
+ const oauthResource = pgTable("oauth_resource", {
154
+ id: text("id").primaryKey(),
155
+ identifier: text("identifier").notNull().unique(),
156
+ name: text("name").notNull(),
157
+ accessTokenTtl: integer("access_token_ttl"),
158
+ refreshTokenTtl: integer("refresh_token_ttl"),
159
+ signingAlgorithm: text("signing_algorithm"),
160
+ signingKeyId: text("signing_key_id"),
161
+ allowedScopes: text("allowed_scopes").array(),
162
+ customClaims: jsonb("custom_claims"),
163
+ dpopBoundAccessTokensRequired: boolean("dpop_bound_access_tokens_required").default(false),
164
+ disabled: boolean("disabled").default(false),
165
+ createdAt: timestamp("created_at"),
166
+ updatedAt: timestamp("updated_at"),
167
+ policyVersion: integer("policy_version").default(1),
168
+ metadata: jsonb("metadata"),
169
+ });
170
+ const oauthClientResource = pgTable("oauth_client_resource", {
171
+ id: text("id").primaryKey(),
172
+ clientId: text("client_id")
173
+ .notNull()
174
+ .references(() => oauthClient.clientId, { onDelete: "cascade" }),
175
+ resourceId: text("resource_id")
176
+ .notNull()
177
+ .references(() => oauthResource.identifier, { onDelete: "cascade" }),
178
+ metadata: jsonb("metadata"),
179
+ createdAt: timestamp("created_at"),
180
+ }, (table) => [
181
+ uniqueIndex("oauthClientResource_clientId_resourceId_uidx").on(table.clientId, table.resourceId),
182
+ index("oauthClientResource_clientId_idx").on(table.clientId),
183
+ index("oauthClientResource_resourceId_idx").on(table.resourceId),
184
+ ]);
185
+ const oauthRefreshToken = pgTable("oauth_refresh_token", {
186
+ id: text("id").primaryKey(),
187
+ token: text("token").notNull().unique(),
188
+ clientId: text("client_id")
189
+ .notNull()
190
+ .references(() => oauthClient.clientId, { onDelete: "cascade" }),
191
+ sessionId: text("session_id").references(() => session.id, { onDelete: "set null" }),
192
+ userId: text("user_id")
193
+ .notNull()
194
+ .references(() => user.id, { onDelete: "cascade" }),
195
+ referenceId: text("reference_id"),
196
+ authorizationCodeId: text("authorization_code_id"),
197
+ resources: text("resources").array(),
198
+ requestedUserInfoClaims: text("requested_user_info_claims").array(),
199
+ expiresAt: timestamp("expires_at").notNull(),
200
+ createdAt: timestamp("created_at").notNull(),
201
+ revoked: timestamp("revoked"),
202
+ rotatedAt: timestamp("rotated_at"),
203
+ rotationReplayResponse: text("rotation_replay_response"),
204
+ rotationReplayExpiresAt: timestamp("rotation_replay_expires_at"),
205
+ authTime: timestamp("auth_time"),
206
+ confirmation: jsonb("confirmation"),
207
+ scopes: text("scopes").array().notNull(),
208
+ }, (table) => [
209
+ index("oauthRefreshToken_clientId_idx").on(table.clientId),
210
+ index("oauthRefreshToken_sessionId_idx").on(table.sessionId),
211
+ index("oauthRefreshToken_userId_idx").on(table.userId),
212
+ index("oauthRefreshToken_authorizationCodeId_idx").on(table.authorizationCodeId),
213
+ ]);
214
+ const oauthAccessToken = pgTable("oauth_access_token", {
215
+ id: text("id").primaryKey(),
216
+ token: text("token").notNull().unique(),
217
+ clientId: text("client_id")
218
+ .notNull()
219
+ .references(() => oauthClient.clientId, { onDelete: "cascade" }),
220
+ sessionId: text("session_id").references(() => session.id, { onDelete: "set null" }),
221
+ userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
222
+ referenceId: text("reference_id"),
223
+ authorizationCodeId: text("authorization_code_id"),
224
+ resources: text("resources").array(),
225
+ requestedUserInfoClaims: text("requested_user_info_claims").array(),
226
+ refreshId: text("refresh_id").references(() => oauthRefreshToken.id, { onDelete: "cascade" }),
227
+ expiresAt: timestamp("expires_at").notNull(),
228
+ createdAt: timestamp("created_at").notNull(),
229
+ revoked: timestamp("revoked"),
230
+ confirmation: jsonb("confirmation"),
231
+ scopes: text("scopes").array().notNull(),
232
+ }, (table) => [
233
+ index("oauthAccessToken_clientId_idx").on(table.clientId),
234
+ index("oauthAccessToken_sessionId_idx").on(table.sessionId),
235
+ index("oauthAccessToken_userId_idx").on(table.userId),
236
+ index("oauthAccessToken_authorizationCodeId_idx").on(table.authorizationCodeId),
237
+ index("oauthAccessToken_refreshId_idx").on(table.refreshId),
238
+ ]);
239
+ const oauthConsent = pgTable("oauth_consent", {
240
+ id: text("id").primaryKey(),
241
+ clientId: text("client_id")
242
+ .notNull()
243
+ .references(() => oauthClient.clientId, { onDelete: "cascade" }),
244
+ userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
245
+ referenceId: text("reference_id"),
246
+ resources: text("resources").array(),
247
+ requestedUserInfoClaims: text("requested_user_info_claims").array(),
248
+ scopes: text("scopes").array().notNull(),
249
+ createdAt: timestamp("created_at").notNull(),
250
+ updatedAt: timestamp("updated_at").notNull(),
251
+ }, (table) => [index("oauthConsent_clientId_idx").on(table.clientId), index("oauthConsent_userId_idx").on(table.userId)]);
252
+ const oauthClientAssertion = pgTable("oauth_client_assertion", {
253
+ id: text("id").primaryKey(),
254
+ expiresAt: timestamp("expires_at").notNull(),
255
+ });
256
+ return { jwks, oauthClient, oauthResource, oauthClientResource, oauthRefreshToken, oauthAccessToken, oauthConsent, oauthClientAssertion };
257
+ }
258
+ export function authSchema(options = {}) {
259
+ const admin = options.admin ?? false;
260
+ const mcp = options.mcp ?? false;
261
+ const user = admin ? buildUserAdmin() : buildUser();
262
+ const session = admin ? buildSessionAdmin(user) : buildSession(user);
263
+ const tables = { user, session, ...buildShared(user), ...(mcp ? buildMcp(user, session) : {}) };
264
+ return tables;
265
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `createAuth()`: better-auth for a Product, from explicit options. The Product's `src/lib/auth.ts` calls it once with
3
+ * its database, its tables (`authSchema`), its env and config; everything else in this package reads the result.
4
+ */
5
+ import type { RequestHandler } from "@sveltejs/kit";
6
+ import type { Handle } from "@sveltejs/kit/hooks";
7
+ import { betterAuth, type BetterAuthPlugin } from "better-auth";
8
+ import { admin as adminPlugin } from "better-auth/plugins";
9
+ import { twoFactor } from "better-auth/plugins/two-factor";
10
+ import { sveltekitCookies } from "better-auth/svelte-kit";
11
+ import type { Mailer } from "@vonall/mail";
12
+ import { roles, ac } from "../permissions.js";
13
+ import type { AuthSchema } from "../schema/index.js";
14
+ import { type AuthLog, type Db } from "./registry.js";
15
+ import { type SignupMode } from "../types.js";
16
+ export type SocialProvider = {
17
+ clientId: string;
18
+ clientSecret: string;
19
+ };
20
+ export type CreateAuthOptions = {
21
+ /** The Product's Drizzle database. */
22
+ db: Db;
23
+ /** The Product's tables from `authSchema()`, the same objects its `schema/` re-exports. */
24
+ schema: AuthSchema;
25
+ /** BETTER_AUTH_SECRET. */
26
+ secret: string;
27
+ /** The app's own address (ORIGIN), e.g. https://blueprint.vonall.ch. */
28
+ origin: string;
29
+ appName: string;
30
+ /** LOCALE, e.g. "en-GB" or "de-CH": dates in mails. */
31
+ locale: string;
32
+ signupMode: SignupMode;
33
+ /** The Product's mailer (`@vonall/mail`); off without SMTP, which the signup mode `open` and the reset respect. */
34
+ mailer: Mailer;
35
+ /** The Admin option: the admin plugin and roles, the first account becomes admin. Matches `authSchema({ admin })`. */
36
+ admin?: boolean;
37
+ /** Social logins with keys; a provider without keys is left out (its button never shows). */
38
+ social?: {
39
+ github?: SocialProvider | null;
40
+ google?: SocialProvider | null;
41
+ };
42
+ /** More better-auth plugins, e.g. the MCP endpoint's (`@vonall/mcp`). */
43
+ plugins?: BetterAuthPlugin[];
44
+ /** More rate limit rules, by path (`@vonall/mcp` adds `/oauth2/register`). */
45
+ rateLimitRules?: Record<string, {
46
+ window: number;
47
+ max: number;
48
+ }>;
49
+ log?: AuthLog;
50
+ };
51
+ /**
52
+ * The instance's type, with every plugin of this package: what `App.Locals` and the Products' code infer from. The admin
53
+ * plugin is in it whether or not the option is on, so `user.role` exists; without the option it is left out at runtime
54
+ * and nobody has a role.
55
+ */
56
+ type FullPlugins = [ReturnType<typeof twoFactor>, ReturnType<typeof adminPlugin<{
57
+ ac: typeof ac;
58
+ roles: typeof roles;
59
+ }>>, ReturnType<typeof sveltekitCookies>];
60
+ export type Auth = ReturnType<typeof betterAuth<{
61
+ plugins: FullPlugins;
62
+ }>>;
63
+ /** The admin plugin's columns, named here because the plugin's type doesn't carry them into the session's user. */
64
+ export type AdminFields = {
65
+ role?: string | null;
66
+ banned?: boolean | null;
67
+ banReason?: string | null;
68
+ banExpires?: Date | null;
69
+ };
70
+ /** The user and the session as the hooks put them into `locals` (a Product's `app.d.ts` uses these). */
71
+ export type AuthUser = Auth["$Infer"]["Session"]["user"] & AdminFields;
72
+ export type AuthSession = Auth["$Infer"]["Session"]["session"];
73
+ export type CreatedAuth = {
74
+ /** The better-auth instance: `auth.api`, `auth.handler`, `auth.$context`. */
75
+ auth: Auth;
76
+ /** The hooks handle: the session into `locals.user`/`locals.session`, and better-auth's own routes (/api/auth/*). */
77
+ handle: Handle;
78
+ /** For `src/routes/api/auth/[...auth]/+server.ts`: `export const GET = apiHandler, POST = apiHandler`. */
79
+ apiHandler: RequestHandler;
80
+ /** The social logins that have keys; the login, signup and settings pages show only these. */
81
+ providers: {
82
+ github: boolean;
83
+ google: boolean;
84
+ };
85
+ };
86
+ export declare function createAuth(options: CreateAuthOptions): CreatedAuth;
87
+ export {};
@@ -0,0 +1,158 @@
1
+ import { getRequestEvent } from "$app/server";
2
+ import { betterAuth } from "better-auth";
3
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
4
+ import { APIError } from "better-auth/api";
5
+ import { admin as adminPlugin } from "better-auth/plugins";
6
+ import { twoFactor } from "better-auth/plugins/two-factor";
7
+ import { sveltekitCookies, svelteKitHandler } from "better-auth/svelte-kit";
8
+ import { m } from "../messages.js";
9
+ import { roles, ac } from "../permissions.js";
10
+ import { hashPassword, verifyPassword } from "./crypto.js";
11
+ import { consoleLog, 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";
15
+ import { MIN_PASSWORD_LENGTH } from "../types.js";
16
+ import { nameFromEmail } from "../util/names.js";
17
+ /** The Einladung token the browser carries from its link (`/einladung/[token]`), if any. */
18
+ function einladungAusCookie() {
19
+ try {
20
+ return getRequestEvent().cookies.get(EINLADUNG_COOKIE);
21
+ }
22
+ catch {
23
+ return undefined; // outside a request, e.g. in scripts
24
+ }
25
+ }
26
+ const hasKeys = (p) => Boolean(p?.clientId && p.clientSecret && p.clientId !== "dummy");
27
+ function build(options) {
28
+ const { secret, origin, appName, signupMode, admin = false, social = {}, plugins = [], rateLimitRules = {}, log = consoleLog } = options;
29
+ const baseURL = origin || "http://localhost";
30
+ return betterAuth({
31
+ appName,
32
+ baseURL,
33
+ secret: secret || "dummy-secret-for-build-purposes-only",
34
+ database: drizzleAdapter(options.db, { provider: "pg", schema: options.schema }),
35
+ account: {
36
+ // A GitHub or Google login joins the account with the same email, when the provider confirms that email
37
+ accountLinking: { enabled: true },
38
+ },
39
+ advanced: {
40
+ // Behind Coolify's Traefik: it sets X-Real-Ip to the client address itself, so clients can't fake it (the app's
41
+ // port is never published, Traefik is the only way in)
42
+ ipAddress: { ipAddressHeaders: ["x-real-ip"] },
43
+ },
44
+ rateLimit: {
45
+ enabled: true,
46
+ storage: "database",
47
+ window: 60,
48
+ max: 100,
49
+ customRules: {
50
+ "/sign-in/*": { window: 60, max: 10 },
51
+ "/sign-up/*": { window: 60, max: 10 },
52
+ "/two-factor/*": { window: 60, max: 10 },
53
+ "/request-password-reset": { window: 300, max: 3 },
54
+ ...rateLimitRules,
55
+ },
56
+ },
57
+ databaseHooks: {
58
+ user: {
59
+ create: {
60
+ before: async (user) => {
61
+ // The signup mode decides: an Einladung, or (open) mail for the confirmation link. The very first account
62
+ // (the admin) needs neither and counts as confirmed.
63
+ const { state, firstAccount } = await EinladungService.signup(einladungAusCookie());
64
+ if (state === "needs_einladung")
65
+ throw new APIError("FORBIDDEN", { message: m().needsEinladung });
66
+ if (state === "no_mail")
67
+ throw new APIError("FORBIDDEN", { message: m().noMail });
68
+ if (firstAccount)
69
+ user.emailVerified = true;
70
+ // Signup with email and password sends the email's local part as the name; make it readable
71
+ if (user.email && (!user.name || user.name === user.email.split("@")[0]))
72
+ user.name = nameFromEmail(user.email);
73
+ return { data: user };
74
+ },
75
+ after: async (user) => {
76
+ // The first user becomes admin
77
+ if (admin && (await UserRepo.countUsers()) === 1) {
78
+ log.info("First user created, bootstrapping global role to admin");
79
+ await UserRepo.setRole(user.id, "admin");
80
+ }
81
+ const token = einladungAusCookie();
82
+ if (!token)
83
+ return;
84
+ const angenommen = await EinladungService.annehmen(user.id, token);
85
+ if (angenommen.isErr())
86
+ log.warn("Einladung could not be accepted after signup", { userId: user.id, kind: angenommen.error.kind });
87
+ getRequestEvent().cookies.delete(EINLADUNG_COOKIE, { path: "/" });
88
+ },
89
+ },
90
+ },
91
+ },
92
+ emailAndPassword: {
93
+ enabled: true,
94
+ password: { hash: hashPassword, verify: verifyPassword },
95
+ minPasswordLength: MIN_PASSWORD_LENGTH,
96
+ // Signup mode open: anyone may sign up, so the email address is confirmed before the account can be used
97
+ requireEmailVerification: signupMode === "open",
98
+ sendResetPassword: async ({ user, url }) => {
99
+ await sendPasswordResetEmail(user, url);
100
+ },
101
+ },
102
+ emailVerification: {
103
+ sendOnSignUp: signupMode === "open",
104
+ sendOnSignIn: signupMode === "open",
105
+ autoSignInAfterVerification: true,
106
+ sendVerificationEmail: async ({ user, url }) => {
107
+ if (!user.emailVerified)
108
+ await sendVerifyEmail(user, url);
109
+ },
110
+ },
111
+ socialProviders: {
112
+ ...(hasKeys(social.github) ? { github: social.github } : {}),
113
+ ...(hasKeys(social.google) ? { google: social.google } : {}),
114
+ },
115
+ user: {
116
+ changeEmail: { enabled: true, updateEmailWithoutVerification: true },
117
+ },
118
+ session: {
119
+ // No cookie cache: every request reads the session and the user, so a new role (the first account's, one set by
120
+ // an admin) and a ban count at once instead of after up to 5 minutes; for a small app that one query is nothing
121
+ cookieCache: { enabled: false },
122
+ },
123
+ plugins: [
124
+ twoFactor(),
125
+ ...(admin ? [adminPlugin({ ac, roles })] : []),
126
+ ...plugins,
127
+ sveltekitCookies(getRequestEvent), // Ensure cookies are populated in SvelteKit
128
+ ],
129
+ });
130
+ }
131
+ export function createAuth(options) {
132
+ const auth = build(options);
133
+ const { social = {} } = options;
134
+ setRegistry({
135
+ db: options.db,
136
+ schema: options.schema,
137
+ appName: options.appName,
138
+ origin: options.origin,
139
+ locale: options.locale,
140
+ signupMode: options.signupMode,
141
+ mailer: options.mailer,
142
+ admin: options.admin ?? false,
143
+ log: options.log ?? consoleLog,
144
+ auth,
145
+ providers: { github: hasKeys(social.github), google: hasKeys(social.google) },
146
+ });
147
+ const handle = async ({ event, resolve }) => {
148
+ const current = await auth.api.getSession({ headers: event.request.headers });
149
+ if (current) {
150
+ event.locals.session = current.session;
151
+ event.locals.user = current.user;
152
+ }
153
+ const { building } = await import("$app/env");
154
+ return svelteKitHandler({ event, resolve, auth, building });
155
+ };
156
+ const apiHandler = ({ request }) => auth.handler(request);
157
+ return { auth, handle, apiHandler, providers: { github: hasKeys(social.github), google: hasKeys(social.google) } };
158
+ }
@@ -0,0 +1,5 @@
1
+ export declare function hashPassword(password: string): Promise<string>;
2
+ export declare function verifyPassword(data: {
3
+ password: string;
4
+ hash: string;
5
+ }): Promise<boolean>;
@@ -0,0 +1,15 @@
1
+ import { hash, verify } from "@node-rs/argon2";
2
+ const opts = {
3
+ memoryCost: 65536, // 64 MiB
4
+ timeCost: 3, // 3 iterations
5
+ parallelism: 4, // 4 lanes
6
+ outputLen: 32, // 32 bytes
7
+ algorithm: 2, // Argon2id
8
+ };
9
+ export async function hashPassword(password) {
10
+ return await hash(password, opts);
11
+ }
12
+ export async function verifyPassword(data) {
13
+ const { password, hash: passwordHash } = data;
14
+ return await verify(passwordHash, password, opts);
15
+ }
@@ -0,0 +1,18 @@
1
+ import { type RemoteFormInput, type RemoteFormInvalidField } from "$app/server";
2
+ import type { z } from "zod";
3
+ import type { SessionUser } from "../types.js";
4
+ type User = SessionUser;
5
+ /** The logged-in user of the current request, else a 401. */
6
+ export declare function requireUser(): User;
7
+ /** The logged-in admin of the current request, else a 401 or 403. */
8
+ export declare function requireAdmin(): User;
9
+ export declare function protectedQuery<Result>(callback: (user: User) => Result | Promise<Result>): import("$app/server").RemoteQueryFunction<void, Result, void>;
10
+ export declare function protectedSchemaQuery<Schema extends z.ZodType, Result>(schema: Schema, callback: (data: z.output<Schema>, user: User) => Result | Promise<Result>): import("$app/server").RemoteQueryFunction<import("better-auth").StandardSchemaV1.InferInput<Schema>, Result, import("better-auth").StandardSchemaV1.InferOutput<Schema>>;
11
+ export declare function protectedCommand<Result>(callback: (user: User) => Result | Promise<Result>): import("$app/server").RemoteCommand<void, Result>;
12
+ export declare function protectedSchemaCommand<Schema extends z.ZodType, Result>(schema: Schema, callback: (data: z.output<Schema>, user: User) => Result | Promise<Result>): import("$app/server").RemoteCommand<import("better-auth").StandardSchemaV1.InferInput<Schema>, Result>;
13
+ export declare function protectedForm<Schema extends z.ZodType<RemoteFormInput, RemoteFormInput>, Result>(schema: Schema, callback: (data: z.output<Schema>, issue: RemoteFormInvalidField<z.input<Schema>>, user: User) => Result | Promise<Result>): import("$app/server").RemoteForm<RemoteFormInput, Result>;
14
+ export declare function adminQuery<Result>(callback: (user: User) => Result | Promise<Result>): import("$app/server").RemoteQueryFunction<void, Result, void>;
15
+ export declare function adminSchemaQuery<Schema extends z.ZodType, Result>(schema: Schema, callback: (data: z.output<Schema>, user: User) => Result | Promise<Result>): import("$app/server").RemoteQueryFunction<import("better-auth").StandardSchemaV1.InferInput<Schema>, Result, import("better-auth").StandardSchemaV1.InferOutput<Schema>>;
16
+ export declare function adminCommand<Result>(callback: (user: User) => Result | Promise<Result>): import("$app/server").RemoteCommand<void, Result>;
17
+ export declare function adminSchemaCommand<Schema extends z.ZodType, Result>(schema: Schema, callback: (data: z.output<Schema>, user: User) => Result | Promise<Result>): import("$app/server").RemoteCommand<import("better-auth").StandardSchemaV1.InferInput<Schema>, Result>;
18
+ export {};
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The login and permission wrappers for a Product's remote functions: `protected…` needs a logged-in user, `admin…`
3
+ * an admin (the Admin option). Each returns a query, command or form, so a `.remote.ts` file may export it.
4
+ */
5
+ import { error } from "@sveltejs/kit";
6
+ import { command, form, getRequestEvent, query } from "$app/server";
7
+ import { m } from "../messages.js";
8
+ function throwUnauthorized() {
9
+ throw error(401, { message: m().unauthorized, id: "unauthorized", timestamp: new Date().toISOString() });
10
+ }
11
+ function throwForbidden() {
12
+ throw error(403, { message: m().forbidden, id: "forbidden", timestamp: new Date().toISOString() });
13
+ }
14
+ /** The logged-in user of the current request, else a 401. */
15
+ export function requireUser() {
16
+ const user = getRequestEvent()?.locals.user;
17
+ if (!user)
18
+ throwUnauthorized();
19
+ return user;
20
+ }
21
+ /** The logged-in admin of the current request, else a 401 or 403. */
22
+ export function requireAdmin() {
23
+ const user = requireUser();
24
+ if (user.role !== "admin")
25
+ throwForbidden();
26
+ return user;
27
+ }
28
+ /**
29
+ * SvelteKit types validated input as the schema's Standard Schema output; for a Zod schema that is
30
+ * its `z.output`, which TypeScript can't prove for a generic schema.
31
+ */
32
+ function parsed(data) {
33
+ return data;
34
+ }
35
+ export function protectedQuery(callback) {
36
+ return query(async () => callback(requireUser()));
37
+ }
38
+ export function protectedSchemaQuery(schema, callback) {
39
+ return query(schema, async (data) => callback(parsed(data), requireUser()));
40
+ }
41
+ export function protectedCommand(callback) {
42
+ return command(async () => callback(requireUser()));
43
+ }
44
+ export function protectedSchemaCommand(schema, callback) {
45
+ return command(schema, async (data) => callback(parsed(data), requireUser()));
46
+ }
47
+ export function protectedForm(schema, callback) {
48
+ // form() checks the schema's booleans with a conditional type that a generic Schema can't resolve
49
+ return form(schema, async (data, issue) => callback(parsed(data), issue, requireUser()));
50
+ }
51
+ export function adminQuery(callback) {
52
+ return query(async () => callback(requireAdmin()));
53
+ }
54
+ export function adminSchemaQuery(schema, callback) {
55
+ return query(schema, async (data) => callback(parsed(data), requireAdmin()));
56
+ }
57
+ export function adminCommand(callback) {
58
+ return command(async () => callback(requireAdmin()));
59
+ }
60
+ export function adminSchemaCommand(schema, callback) {
61
+ return command(schema, async (data) => callback(parsed(data), requireAdmin()));
62
+ }
@@ -0,0 +1,9 @@
1
+ export { createAuth, type AdminFields, type Auth, type AuthSession, type AuthUser, type CreateAuthOptions, type CreatedAuth, type SocialProvider } from "./create.js";
2
+ 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 { 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 { KontoService } from "./service/konto.service.js";
9
+ export { sendEinladungEmail, sendPasswordResetEmail, sendVerifyEmail } from "./service/mail.js";
@@ -0,0 +1,9 @@
1
+ export { createAuth } from "./create.js";
2
+ 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 { 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 { KontoService } from "./service/konto.service.js";
9
+ export { sendEinladungEmail, sendPasswordResetEmail, sendVerifyEmail } from "./service/mail.js";