@podosoft/podokit 1.6.2 → 1.6.4

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 (28) hide show
  1. package/dist/deploy-checks.d.ts +20 -2
  2. package/dist/deploy-checks.js +83 -4
  3. package/dist/deploy-compose-render.js +15 -4
  4. package/dist/deploy-compose.js +211 -27
  5. package/dist/deploy.js +16 -0
  6. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -4
  7. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -2
  8. package/dist/templates/fullstack/tests/README.md +11 -0
  9. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +28 -2
  10. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +24 -0
  11. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
  12. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
  13. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +16 -4
  14. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
  15. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
  16. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
  17. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
  18. package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
  19. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +31 -8
  20. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +531 -29
  21. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +253 -60
  22. package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.spec.ts +232 -0
  23. package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.ts +68 -0
  24. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +53 -47
  25. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +1 -1
  26. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +9 -2
  27. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +60 -15
  28. package/package.json +1 -1
@@ -1,12 +1,15 @@
1
- import type { BetterAuthPlugin } from "better-auth";
1
+ import type { BetterAuthOptions, BetterAuthPlugin } from "better-auth";
2
2
  import { createAuthMiddleware, isAPIError } from "better-auth/api";
3
+ import { parseSetCookieHeader } from "better-auth/cookies";
3
4
  import { clientAddressOf } from "../core/client-address";
4
- import { recordAudit } from "./audit-events";
5
+ import { recordAudit, type AuditEntry } from "./audit-events";
5
6
  import { auditEnabled } from "./audit-enabled";
6
7
 
7
- // Endpoints that sign a user in, with the method their auth.login entry records.
8
- // ctx.path is the route pattern an endpoint was registered with, not the request
9
- // URL, so every social provider's callback arrives as "/callback/:id".
8
+ // The method that auth.login and auth.signup record for Better Auth's sign-in
9
+ // endpoints. A sign-in through any other endpoint, such as one of a plugin the
10
+ // application adds, is recorded too, with the endpoint's path as its method. ctx.path is
11
+ // the route pattern an endpoint was registered with, not the request URL, so
12
+ // every social provider's callback arrives as "/callback/:id".
10
13
  export const SIGN_IN_METHODS: Readonly<Record<string, string>> = {
11
14
  "/sign-in/email": "password",
12
15
  "/sign-in/username": "username",
@@ -24,15 +27,35 @@ export const SIGN_IN_METHODS: Readonly<Record<string, string>> = {
24
27
  "/email-otp/verify-email": "email-otp-verification",
25
28
  };
26
29
 
30
+ // Endpoints that can end with a session the request did not come with without
31
+ // signing anyone in: a sign-up, recorded as auth.signup; an administrator
32
+ // starting or ending an impersonation; a switch to another session the browser
33
+ // already holds; and requests that replace the signed-in user's own session.
34
+ export const NOT_SIGN_INS: ReadonlySet<string> = new Set([
35
+ "/sign-up/email",
36
+ "/admin/impersonate-user",
37
+ "/admin/stop-impersonating",
38
+ "/multi-session/set-active",
39
+ "/multi-session/revoke",
40
+ "/two-factor/enable",
41
+ "/two-factor/disable",
42
+ "/change-password",
43
+ ]);
44
+
27
45
  // Other auth and admin endpoints, with the action each records once it succeeds.
46
+ // A new account records auth.signup from the plugin's database hook instead, so
47
+ // every endpoint that creates one is covered.
28
48
  const ACTIONS: Readonly<Record<string, string>> = {
29
- "/sign-up/email": "auth.signup",
30
49
  "/sign-out": "auth.logout",
31
50
  "/change-password": "auth.password_change",
32
51
  "/change-email": "auth.email_change",
52
+ "/email-otp/change-email": "auth.email_change",
33
53
  "/reset-password": "auth.password_reset",
54
+ "/email-otp/reset-password": "auth.password_reset",
55
+ "/phone-number/reset-password": "auth.password_reset",
34
56
  "/send-verification-email": "auth.verification_sent",
35
57
  "/verify-email": "auth.email_verified",
58
+ "/email-otp/verify-email": "auth.email_verified",
36
59
  "/update-user": "account.update",
37
60
  "/delete-user": "account.delete",
38
61
  "/revoke-session": "session.revoke",
@@ -53,9 +76,68 @@ const ACTIONS: Readonly<Record<string, string>> = {
53
76
  "/admin/stop-impersonating": "user.stop_impersonate",
54
77
  };
55
78
 
79
+ interface AuditUser {
80
+ id: string;
81
+ name?: string | null;
82
+ email?: string | null;
83
+ }
84
+
56
85
  interface SessionRecord {
57
- session: { token: string };
58
- user: { id: string; name?: string | null; email?: string | null };
86
+ session: { token: string; impersonatedBy?: string | null };
87
+ user: AuditUser;
88
+ }
89
+
90
+ // What the plugin's database hooks saw one request do. Better Auth copies its
91
+ // context for every endpoint call and hands that copy, ctx.context, to the
92
+ // endpoint, to the database hooks the endpoint triggers, and to its own hooks,
93
+ // so the copy identifies the request.
94
+ interface Observed {
95
+ /** The user /admin/create-user created. */
96
+ created?: AuditUser;
97
+ /** The user the request last updated, such as the one an email link verified. */
98
+ updated?: AuditUser;
99
+ /** The user whose password-reset link the request presented. */
100
+ resetUserId?: string;
101
+ /** The owners of the sessions the request ended, in order. */
102
+ endedSessions: string[];
103
+ }
104
+
105
+ const observations = new WeakMap<object, Observed>();
106
+
107
+ function observe(context: object): Observed {
108
+ let observed = observations.get(context);
109
+ if (!observed) {
110
+ observed = { endedSessions: [] };
111
+ observations.set(context, observed);
112
+ }
113
+ return observed;
114
+ }
115
+
116
+ interface RequestShape {
117
+ path?: string;
118
+ params?: unknown;
119
+ body?: unknown;
120
+ }
121
+
122
+ // How a request signed someone in or up: the name in SIGN_IN_METHODS, otherwise
123
+ // the endpoint's path, and the provider of a social sign-in.
124
+ function methodOf({ path = "", params, body }: RequestShape): Record<string, string> {
125
+ const method = path === "/sign-up/email" ? "password" : (SIGN_IN_METHODS[path] ?? path);
126
+ if (method !== "social") return { method };
127
+ const provider = (params as { id?: string } | undefined)?.id ?? (body as { provider?: string } | undefined)?.provider;
128
+ return provider ? { method, provider } : { method };
129
+ }
130
+
131
+ interface SignInContext {
132
+ path: string;
133
+ context: {
134
+ session: SessionRecord | null;
135
+ responseHeaders?: Headers;
136
+ secret: string;
137
+ authCookies: { sessionToken: { name: string } };
138
+ internalAdapter: { findSession: (token: string) => Promise<unknown> };
139
+ };
140
+ getSignedCookie: (name: string, secret: string) => Promise<string | null | false>;
59
141
  }
60
142
 
61
143
  // After hooks run for failed requests too, with the APIError the endpoint threw
@@ -73,19 +155,33 @@ function failed(returned: unknown): boolean {
73
155
  }
74
156
  }
75
157
 
76
- // A sign-in ends with a session the request did not come with. Two requests end
77
- // with a new session without signing anyone in: confirming two-factor replaces
78
- // the signed-in user's session, which two-factor loaded into ctx.context.session,
79
- // and verifying the signed-in user's email sets the cookie of the session the
80
- // request came with again. oauthProvider() also puts the new session into
81
- // ctx.context.session when a sign-in resumes an authorization request.
82
- function signedIn(
83
- created: SessionRecord | null | undefined,
84
- current: SessionRecord | null | undefined,
85
- presentedToken: string | null | false,
86
- ): created is SessionRecord {
87
- if (!created || created.session.token === presentedToken) return false;
88
- return !(current && current.user.id === created.user.id && current.session.token !== created.session.token);
158
+ // A sign-in ends with a session the request did not come with, on any endpoint
159
+ // but those in NOT_SIGN_INS.
160
+ // - Its response sets the session cookie to the new session. An endpoint that
161
+ // another one dispatches, as oauthProvider() runs /oauth2/authorize when a
162
+ // sign-in resumes an authorization request, inherits that request's new
163
+ // session without setting it.
164
+ // - A request that refreshes its own session, such as a session read that
165
+ // extends it or a profile update, sets it again: the session cookie it sent,
166
+ // or the one bearer() made of its Authorization header, has the same token.
167
+ // - Confirming two-factor replaces the signed-in user's session, which the
168
+ // endpoint loaded into ctx.context.session.
169
+ // - When the session the endpoint loaded is the new one, a stored session is a
170
+ // sign-in: oauthProvider() loads it when a sign-in resumes an authorization
171
+ // request. The session of an API key request (apiKey's
172
+ // enableSessionForAPIKeys) has the key as its token and is not stored.
173
+ async function signedIn(ctx: SignInContext, created: SessionRecord): Promise<boolean> {
174
+ if (NOT_SIGN_INS.has(ctx.path)) return false;
175
+ const { token } = created.session;
176
+ const set = parseSetCookieHeader(ctx.context.responseHeaders?.get("set-cookie") ?? "");
177
+ const cookie = set.get(ctx.context.authCookies.sessionToken.name)?.value;
178
+ if (decodeURIComponent(cookie ?? "").split(".")[0] !== token) return false;
179
+ const presented = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
180
+ if (token === presented) return false;
181
+ const current = ctx.context.session;
182
+ if (!current) return true;
183
+ if (current.session.token !== token) return current.user.id !== created.user.id;
184
+ return Boolean(await ctx.context.internalAdapter.findSession(token));
89
185
  }
90
186
 
91
187
  export interface AuditPluginOptions {
@@ -93,70 +189,167 @@ export interface AuditPluginOptions {
93
189
  enabled?: () => Promise<boolean>;
94
190
  }
95
191
 
192
+ type DatabaseHooks = NonNullable<BetterAuthOptions["databaseHooks"]>;
193
+ type HookContext = Parameters<NonNullable<NonNullable<NonNullable<DatabaseHooks["user"]>["create"]>["after"]>>[1];
194
+
195
+ const who = (user: AuditUser | null | undefined): Pick<AuditEntry, "actorId" | "actorName" | "actorEmail"> => ({
196
+ actorId: user?.id ?? null,
197
+ actorName: user?.name ?? null,
198
+ actorEmail: user?.email ?? null,
199
+ });
200
+
96
201
  // Records auth and admin actions through the application audit pipeline. Pushed
97
202
  // after every other plugin, because after hooks run in plugin order and earlier
98
203
  // ones change the outcome: two-factor turns a password sign-in into a pending
99
- // challenge by clearing ctx.context.newSession.
204
+ // challenge by clearing ctx.context.newSession. It runs for every endpoint, the
205
+ // server-side session lookup of each API request included, so a request with
206
+ // neither an action nor a new session returns before any other work.
207
+ //
208
+ // Its database hooks see what a request did to users and sessions, so entries
209
+ // name the user even when the request carries no session: a new account is
210
+ // recorded when it is stored, whichever endpoint creates it and even when the
211
+ // request then fails (a social sign-up that waits for approval gets no
212
+ // session), and a sign-out names the owner of the session it ended.
100
213
  export function auditPlugin(options: AuditPluginOptions = {}): BetterAuthPlugin & { id: "podokit-audit" } {
101
214
  const enabled = options.enabled ?? auditEnabled;
215
+
216
+ async function recordSignUp(user: AuditUser, context: HookContext): Promise<void> {
217
+ if (!(await enabled())) return;
218
+ if (context?.path === "/admin/create-user") {
219
+ // Recorded as user.create by the request's after hook.
220
+ observe(context.context).created = user;
221
+ return;
222
+ }
223
+ await recordAudit({
224
+ action: "auth.signup",
225
+ ...(context ? who(user) : {}),
226
+ targetId: user.id,
227
+ targetLabel: user.email ?? null,
228
+ ip: clientAddressOf(context?.headers) ?? null,
229
+ ...(context ? { metadata: methodOf(context) } : {}),
230
+ });
231
+ }
232
+
233
+ const databaseHooks: DatabaseHooks = {
234
+ user: {
235
+ create: { after: async (user, context) => recordSignUp(user, context) },
236
+ update: {
237
+ after: async (user, context) => {
238
+ if (context) observe(context.context).updated = user;
239
+ },
240
+ },
241
+ },
242
+ session: {
243
+ delete: {
244
+ // deleteWithHooks reads the row before it deletes it, so this costs no query.
245
+ before: async (session, context) => {
246
+ if (context) observe(context.context).endedSessions.push(session.userId);
247
+ },
248
+ },
249
+ },
250
+ };
251
+
102
252
  return {
103
253
  id: "podokit-audit",
254
+ init: () => ({ options: { databaseHooks } }),
104
255
  hooks: {
256
+ before: [
257
+ {
258
+ // The endpoint consumes the link's token, so find its user first.
259
+ matcher: (context) => context.path === "/reset-password",
260
+ handler: createAuthMiddleware(async (ctx) => {
261
+ const token =
262
+ (ctx.body as { token?: string } | undefined)?.token ?? (ctx.query as { token?: string } | undefined)?.token;
263
+ if (!token || !(await enabled())) return;
264
+ try {
265
+ const verification = await ctx.context.internalAdapter.findVerificationValue(`reset-password:${token}`);
266
+ if (verification?.value) observe(ctx.context).resetUserId = verification.value;
267
+ } catch {
268
+ // The entry then names no account; the reset itself goes ahead.
269
+ }
270
+ }),
271
+ },
272
+ ],
105
273
  after: [
106
274
  {
107
- matcher: (context) => Boolean(context.path && (SIGN_IN_METHODS[context.path] || ACTIONS[context.path])),
275
+ matcher: (context) => Boolean(context.path),
108
276
  handler: createAuthMiddleware(async (ctx) => {
277
+ const action = ACTIONS[ctx.path];
278
+ const created = ctx.context.newSession;
279
+ if (!action && !created) return;
109
280
  if (failed(ctx.context.returned)) return;
281
+ const login = created && (await signedIn(ctx, created)) ? created : null;
282
+ if (!action && !login) return;
110
283
  // AuditService checks the toggle again on write; checking here as well
111
284
  // skips the lookups below when auditing is off.
112
285
  if (!(await enabled())) return;
113
286
  const ip = clientAddressOf(ctx.headers) ?? null;
287
+ const seen = observations.get(ctx.context);
288
+ // A failed lookup leaves the entry without that user; it never fails the request.
289
+ const quietly = async (find: () => Promise<AuditUser | null | undefined>): Promise<AuditUser | null> => {
290
+ try {
291
+ return (await find()) ?? null;
292
+ } catch {
293
+ return null;
294
+ }
295
+ };
296
+ const userById = async (id: string | null | undefined): Promise<AuditUser | null> =>
297
+ id ? quietly(() => ctx.context.internalAdapter.findUserById(id)) : null;
114
298
 
115
- const action = ACTIONS[ctx.path];
116
- if (action) {
117
- const actor = ctx.context.session?.user ?? ctx.context.newSession?.user;
118
- const body = (ctx.body ?? {}) as { email?: string; userId?: string };
299
+ if (action === "auth.logout") {
300
+ // One entry per account whose session ended: multiSession() signs
301
+ // out every account of the browser. A sign-out without a session
302
+ // ends none and records nothing.
303
+ for (const id of new Set(seen?.endedSessions)) {
304
+ const user = (await userById(id)) ?? { id };
305
+ await recordAudit({ action, ...who(user), targetId: id, targetLabel: user.email ?? null, ip });
306
+ }
307
+ } else if (action) {
308
+ const session = ctx.context.session;
309
+ const body = (ctx.body ?? {}) as { email?: string; userId?: string; phoneNumber?: string };
310
+ let actor: AuditUser | null = session?.user ?? ctx.context.newSession?.user ?? null;
311
+ // The user the request acted on when its body does not name one.
312
+ let subject: AuditUser | null = null;
313
+ if (action === "user.create") subject = seen?.created ?? null;
314
+ else if (action === "user.session_revoke") subject = await userById(seen?.endedSessions[0]);
315
+ else if (action === "user.stop_impersonate" && session?.session.impersonatedBy) {
316
+ // The session is the impersonated user's; the administrator ends it.
317
+ subject = session.user;
318
+ actor = (await userById(session.session.impersonatedBy)) ?? actor;
319
+ } else if (action === "auth.email_verified") {
320
+ subject = seen?.updated ?? null;
321
+ actor ??= subject;
322
+ } else if (action === "auth.password_reset") {
323
+ // Whoever holds the link or code acts as the account's owner.
324
+ if (ctx.path === "/reset-password") subject = await userById(seen?.resetUserId);
325
+ else if (ctx.path === "/email-otp/reset-password" && body.email) {
326
+ const email = body.email;
327
+ subject = await quietly(async () => (await ctx.context.internalAdapter.findUserByEmail(email))?.user);
328
+ } else if (ctx.path === "/phone-number/reset-password" && body.phoneNumber) {
329
+ const phoneNumber = body.phoneNumber;
330
+ subject = await quietly(() =>
331
+ ctx.context.adapter.findOne<AuditUser>({ model: "user", where: [{ field: "phoneNumber", value: phoneNumber }] }),
332
+ );
333
+ }
334
+ actor ??= subject;
335
+ }
119
336
 
120
337
  // Prefer a human-readable target: the email in the body, otherwise
121
338
  // the target user's email by id (the id for already-deleted users).
122
- const targetId = body.userId ?? null;
123
- let targetLabel = body.email ?? null;
124
- if (!targetLabel && targetId) {
125
- try {
126
- const target = await ctx.context.internalAdapter.findUserById(targetId);
127
- targetLabel = target?.email ?? targetId;
128
- } catch {
129
- targetLabel = targetId;
130
- }
131
- }
132
- await recordAudit({
133
- action,
134
- actorId: actor?.id ?? null,
135
- actorName: actor?.name ?? null,
136
- actorEmail: actor?.email ?? null,
137
- targetId,
138
- targetLabel,
139
- ip,
140
- });
339
+ const targetId = body.userId ?? subject?.id ?? null;
340
+ let targetLabel = body.email ?? subject?.email ?? body.phoneNumber ?? null;
341
+ if (!targetLabel && targetId) targetLabel = (await userById(targetId))?.email ?? targetId;
342
+ await recordAudit({ action, ...who(actor), targetId, targetLabel, ip });
141
343
  }
142
344
 
143
- const method = SIGN_IN_METHODS[ctx.path];
144
- if (!method) return;
145
- const created = ctx.context.newSession;
146
- const presented = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
147
- if (!signedIn(created, ctx.context.session, presented)) return;
148
- const params = (ctx.params ?? {}) as { id?: string };
149
- const body = (ctx.body ?? {}) as { provider?: string };
150
- const provider = method === "social" ? (params.id ?? body.provider) : undefined;
345
+ if (!login) return;
151
346
  await recordAudit({
152
347
  action: "auth.login",
153
- actorId: created.user.id,
154
- actorName: created.user.name ?? null,
155
- actorEmail: created.user.email ?? null,
156
- targetId: created.user.id,
157
- targetLabel: created.user.email ?? null,
348
+ ...who(login.user),
349
+ targetId: login.user.id,
350
+ targetLabel: login.user.email ?? null,
158
351
  ip,
159
- metadata: provider ? { method, provider } : { method },
352
+ metadata: methodOf(ctx),
160
353
  });
161
354
  }),
162
355
  },
@@ -0,0 +1,232 @@
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import { join } from "node:path";
3
+ import { betterAuth } from "better-auth";
4
+ import { memoryAdapter } from "better-auth/adapters/memory";
5
+ import { getAuthTables } from "better-auth/db";
6
+ import { admin, magicLink } from "better-auth/plugins";
7
+ import { adminEmailProblem, adminEmailsFrom, becomesAdministrator } from "./admin-emails";
8
+ import { databaseHooksFor } from "./auth";
9
+ import { PUBLIC_SIGNUP_DISABLED } from "./feature-gate";
10
+
11
+ const BASE_URL = "http://localhost:5002";
12
+ const PASSWORD = "Podokit3e-Str0ng!pw";
13
+ const ADMIN_EMAIL = "owner@podokit.dev";
14
+
15
+ describe("ADMIN_EMAILS", () => {
16
+ test("keeps placeholders outside production and refuses them in production", () => {
17
+ expect([...adminEmailsFrom(" Admin@Example.com, ,ops@podokit.dev ", false)]).toEqual([
18
+ "admin@example.com",
19
+ "ops@podokit.dev",
20
+ ]);
21
+ expect([...adminEmailsFrom("owner@podokit.dev,ops@mail.podokit.dev", true)]).toHaveLength(2);
22
+ expect(adminEmailsFrom(undefined, true).size).toBe(0);
23
+ expect(adminEmailsFrom("", true).size).toBe(0);
24
+ for (const entry of [
25
+ "admin@example.com",
26
+ "a@mail.example.org",
27
+ "a@example.net",
28
+ "a@host.test",
29
+ "a@host.example",
30
+ "a@host.invalid",
31
+ "a@localhost",
32
+ "admin",
33
+ "a@b",
34
+ ]) {
35
+ expect(() => adminEmailsFrom(`owner@podokit.dev,${entry}`, true)).toThrow(`"${entry}"`);
36
+ }
37
+ });
38
+
39
+ test("stops a production API whose ADMIN_EMAILS holds a placeholder", () => {
40
+ const load = (adminEmails: string): { exitCode: number; stderr: string } => {
41
+ const result = Bun.spawnSync(
42
+ [process.execPath, "--eval", `await import(${JSON.stringify(join(__dirname, "auth.ts"))}); process.exit(0);`],
43
+ {
44
+ env: {
45
+ PATH: process.env.PATH ?? "",
46
+ NODE_ENV: "production",
47
+ ADMIN_EMAILS: adminEmails,
48
+ BETTER_AUTH_SECRET: "admin-emails-production-secret-0123456789",
49
+ BETTER_AUTH_URL: "https://app.podokit.dev",
50
+ },
51
+ stderr: "pipe",
52
+ },
53
+ );
54
+ return { exitCode: result.exitCode, stderr: result.stderr.toString() };
55
+ };
56
+ const placeholder = load("admin@example.com");
57
+ expect(placeholder.exitCode).not.toBe(0);
58
+ expect(placeholder.stderr).toContain('ADMIN_EMAILS must list real administrator addresses in production');
59
+ expect(load("owner@podokit.dev").exitCode).toBe(0);
60
+ });
61
+
62
+ test("names why an entry is no administrator's address", () => {
63
+ expect(adminEmailProblem("admin@example.com")).toBe("a placeholder on a reserved domain");
64
+ expect(adminEmailProblem("someone@example.company")).toBeNull();
65
+ expect(adminEmailProblem("admin")).toBe("not an email address");
66
+ expect(adminEmailProblem("owner@podokit.dev")).toBeNull();
67
+ });
68
+
69
+ test("promotes a listed address only once it is proven", () => {
70
+ const listed = new Set([ADMIN_EMAIL]);
71
+ expect(becomesAdministrator({ email: "Owner@Podokit.dev", emailVerified: true }, false, listed)).toBe(true);
72
+ expect(becomesAdministrator({ email: ADMIN_EMAIL, emailVerified: false }, true, listed)).toBe(true);
73
+ expect(becomesAdministrator({ email: "else@podokit.dev", emailVerified: false }, false, listed)).toBe(false);
74
+ expect(() => becomesAdministrator({ email: ADMIN_EMAIL, emailVerified: false }, false, listed)).toThrow(
75
+ "cannot be registered",
76
+ );
77
+ });
78
+ });
79
+
80
+ interface StartOptions {
81
+ requireSignupApproval?: boolean;
82
+ googleEmailVerified?: boolean;
83
+ }
84
+
85
+ // The account rules of the generated auth.ts on an in-memory database, with
86
+ // sign-up open and ADMIN_EMAILS naming ADMIN_EMAIL.
87
+ function startAuth({ requireSignupApproval = false, googleEmailVerified = true }: StartOptions = {}) {
88
+ const mail = { magicLink: "" };
89
+ const db: Record<string, Record<string, unknown>[]> = {};
90
+ const options = {
91
+ baseURL: BASE_URL,
92
+ secret: "admin-emails-test-secret-0123456789abcdef",
93
+ database: memoryAdapter(db),
94
+ emailAndPassword: { enabled: true, autoSignIn: !requireSignupApproval },
95
+ user: { additionalFields: { signupApproved: { type: "boolean" as const, required: false, defaultValue: true, input: false } } },
96
+ socialProviders: {
97
+ google: {
98
+ clientId: "client",
99
+ clientSecret: "secret",
100
+ getUserInfo: async () => ({
101
+ user: { email: ADMIN_EMAIL, name: "Owner", emailVerified: googleEmailVerified },
102
+ data: {
103
+ sub: "google-1",
104
+ email: ADMIN_EMAIL,
105
+ email_verified: googleEmailVerified,
106
+ name: "Owner",
107
+ given_name: "Owner",
108
+ family_name: "Podokit",
109
+ picture: "https://podokit.dev/owner.png",
110
+ aud: "client",
111
+ azp: "client",
112
+ iss: "https://accounts.google.com",
113
+ iat: 0,
114
+ exp: 0,
115
+ },
116
+ }),
117
+ },
118
+ },
119
+ plugins: [
120
+ magicLink({
121
+ sendMagicLink: async ({ url }) => {
122
+ mail.magicLink = url;
123
+ },
124
+ }),
125
+ admin(),
126
+ ],
127
+ databaseHooks: databaseHooksFor({ requireSignupApproval }, new Set([ADMIN_EMAIL]), async () => true),
128
+ };
129
+ for (const table of Object.values(getAuthTables(options))) db[table.modelName] = [];
130
+ const auth = betterAuth(options);
131
+ const users = (): Array<{ email: string; role?: string; signupApproved?: boolean; emailVerified?: boolean }> =>
132
+ (db.user ?? []) as Array<{ email: string; role?: string; signupApproved?: boolean; emailVerified?: boolean }>;
133
+ const request = (path: string, init: { method?: string; body?: unknown; cookie?: string } = {}): Promise<Response> =>
134
+ auth.handler(
135
+ new Request(path.startsWith("http") ? path : `${BASE_URL}/api/auth${path}`, {
136
+ method: init.method ?? (init.body === undefined ? "GET" : "POST"),
137
+ headers: {
138
+ origin: BASE_URL,
139
+ ...(init.body === undefined ? {} : { "content-type": "application/json" }),
140
+ ...(init.cookie ? { cookie: init.cookie } : {}),
141
+ },
142
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
143
+ redirect: "manual",
144
+ }),
145
+ );
146
+ return { auth, mail, users, request };
147
+ }
148
+
149
+ const cookiesOf = (response: Response): string =>
150
+ response.headers
151
+ .getSetCookie()
152
+ .map((cookie) => cookie.split(";")[0])
153
+ .join("; ");
154
+
155
+ // Signs in with Google, answering the provider's token request.
156
+ async function googleSignIn(started: ReturnType<typeof startAuth>): Promise<Response> {
157
+ const realFetch = globalThis.fetch;
158
+ const tokenEndpoint = spyOn(globalThis, "fetch").mockImplementation(((input: RequestInfo | URL, init?: RequestInit) => {
159
+ const url = input instanceof Request ? input.url : String(input);
160
+ if (url.startsWith("https://oauth2.googleapis.com/token")) {
161
+ return Promise.resolve(Response.json({ access_token: "access", expires_in: 3600, token_type: "Bearer" }));
162
+ }
163
+ return realFetch(input, init);
164
+ }) as typeof fetch);
165
+ try {
166
+ const begin = await started.request("/sign-in/social", { body: { provider: "google", callbackURL: `${BASE_URL}/` } });
167
+ const state = new URL(((await begin.json()) as { url: string }).url).searchParams.get("state") ?? "";
168
+ return await started.request(`/callback/google?code=code&state=${encodeURIComponent(state)}`, {
169
+ cookie: cookiesOf(begin),
170
+ });
171
+ } finally {
172
+ tokenEndpoint.mockRestore();
173
+ }
174
+ }
175
+
176
+ describe("accounts with an ADMIN_EMAILS address", () => {
177
+ test("refuses an unverified email sign-up instead of making an administrator", async () => {
178
+ const open = startAuth();
179
+ const refused = await open.request("/sign-up/email", { body: { email: ADMIN_EMAIL, password: PASSWORD, name: "Claim" } });
180
+ expect(refused.status).toBe(403);
181
+ expect(((await refused.json()) as { code?: string }).code).toBe(PUBLIC_SIGNUP_DISABLED);
182
+ expect(open.users()).toEqual([]);
183
+
184
+ // With sign-up approval on, Better Auth answers a refused sign-up like any
185
+ // other, so the response does not reveal the address; still no account.
186
+ const pending = startAuth({ requireSignupApproval: true });
187
+ const hidden = await pending.request("/sign-up/email", { body: { email: ADMIN_EMAIL, password: PASSWORD, name: "Claim" } });
188
+ expect(hidden.status).toBe(200);
189
+ expect(pending.users()).toEqual([]);
190
+ const signIn = await pending.request("/sign-in/email", { body: { email: ADMIN_EMAIL, password: PASSWORD } });
191
+ expect(signIn.status).toBe(401);
192
+
193
+ // Other addresses sign up as before: pending under the approval policy.
194
+ expect((await pending.request("/sign-up/email", { body: { email: "member@podokit.dev", password: PASSWORD, name: "M" } })).status).toBe(200);
195
+ expect(pending.users()).toEqual([expect.objectContaining({ email: "member@podokit.dev", role: "user", signupApproved: false })]);
196
+ });
197
+
198
+ test("makes an approved administrator of a social sign-in with a verified address", async () => {
199
+ const started = startAuth({ requireSignupApproval: true });
200
+ const callback = await googleSignIn(started);
201
+ expect(callback.status).toBe(302);
202
+ expect(new URL(callback.headers.get("location") ?? "", BASE_URL).searchParams.get("error")).toBeNull();
203
+ expect(started.users()).toEqual([
204
+ expect.objectContaining({ email: ADMIN_EMAIL, role: "admin", signupApproved: true, emailVerified: true }),
205
+ ]);
206
+
207
+ const unverified = startAuth({ requireSignupApproval: true, googleEmailVerified: false });
208
+ const refused = await googleSignIn(unverified);
209
+ expect(refused.status).toBe(302);
210
+ expect(new URL(refused.headers.get("location") ?? "", BASE_URL).searchParams.get("error")).toBeTruthy();
211
+ expect(unverified.users()).toEqual([]);
212
+ });
213
+
214
+ test("makes an administrator of a magic link sign-in, which proves the address", async () => {
215
+ const started = startAuth({ requireSignupApproval: true });
216
+ await started.request("/sign-in/magic-link", { body: { email: ADMIN_EMAIL, callbackURL: `${BASE_URL}/` } });
217
+ expect((await started.request(started.mail.magicLink)).status).toBe(302);
218
+ expect(started.users()).toEqual([expect.objectContaining({ email: ADMIN_EMAIL, role: "admin", signupApproved: true })]);
219
+ });
220
+
221
+ test("creates the administrator that admin:bootstrap asks for", async () => {
222
+ const started = startAuth({ requireSignupApproval: true });
223
+ // scripts/bootstrap-admin.mjs creates the account through this call.
224
+ await started.auth.api.createUser({
225
+ body: { email: ADMIN_EMAIL, password: PASSWORD, name: "Administrator", role: "admin", data: { emailVerified: true, signupApproved: true } },
226
+ });
227
+ expect(started.users()).toEqual([
228
+ expect.objectContaining({ email: ADMIN_EMAIL, role: "admin", signupApproved: true, emailVerified: true }),
229
+ ]);
230
+ expect((await started.request("/sign-in/email", { body: { email: ADMIN_EMAIL, password: PASSWORD } })).status).toBe(200);
231
+ });
232
+ });