Auto-follow existing server admins when a new user registers
Hop-State: A_06FPE85XGRGM7FD155BAF78 Hop-Proposal: R_06FPE85ATWMCJ2G1HP9SMZR Hop-Task: T_06FPE7FQ3T1G2VXH0TY9SR0 Hop-Attempt: AT_06FPE7FQ3RRTGK6K7VYESAR
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { configuredAdminEmails, isConfiguredAdminEmail } from './admin-config';
|
||||
|
||||
describe('admin configuration', () => {
|
||||
it('normalizes and deduplicates configured admin emails', () => {
|
||||
expect(configuredAdminEmails(' Admin@Example.com,owner@example.com,admin@example.com ')).toEqual([
|
||||
'admin@example.com',
|
||||
'owner@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches admin emails case-insensitively', () => {
|
||||
expect(isConfiguredAdminEmail('ADMIN@example.com', 'admin@example.com')).toBe(true);
|
||||
expect(isConfiguredAdminEmail('member@example.com', 'admin@example.com')).toBe(false);
|
||||
expect(isConfiguredAdminEmail(null, 'admin@example.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export function configuredAdminEmails(value = process.env.ADMIN_EMAILS): string[] {
|
||||
return [...new Set(
|
||||
(value || '')
|
||||
.split(',')
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
)];
|
||||
}
|
||||
|
||||
export function isConfiguredAdminEmail(
|
||||
email: string | null | undefined,
|
||||
value = process.env.ADMIN_EMAILS
|
||||
): boolean {
|
||||
if (!email) return false;
|
||||
return configuredAdminEmails(value).includes(email.toLowerCase());
|
||||
}
|
||||
+2
-12
@@ -1,22 +1,12 @@
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { users } from '@/db';
|
||||
import { isConfiguredAdminEmail } from '@/lib/auth/admin-config';
|
||||
|
||||
type User = typeof users.$inferSelect;
|
||||
|
||||
const normalizeList = (value?: string | null) =>
|
||||
(value || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
const adminEmails = normalizeList(process.env.ADMIN_EMAILS);
|
||||
|
||||
export const isAdminUser = (user: User | null | undefined) => {
|
||||
if (!user) return false;
|
||||
if (user.email && adminEmails.length > 0 && adminEmails.includes(user.email.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return isConfiguredAdminEmail(user.email);
|
||||
};
|
||||
|
||||
export async function requireAdmin(): Promise<User> {
|
||||
|
||||
+35
-11
@@ -2,8 +2,8 @@
|
||||
* Authentication Utilities
|
||||
*/
|
||||
|
||||
import { db, users, sessions } from '@/db';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { db, follows, users, sessions } from '@/db';
|
||||
import { eq, inArray, sql } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { generateKeyPair } from '@/lib/crypto/keys';
|
||||
@@ -12,6 +12,7 @@ import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-k
|
||||
import { cookies } from 'next/headers';
|
||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||
import { registrationDisplayName } from '@/lib/auth/display-name';
|
||||
import { configuredAdminEmails } from '@/lib/auth/admin-config';
|
||||
|
||||
const ACTIVE_SESSION_COOKIE_NAME = 'synapsis_session';
|
||||
const SESSION_COOKIE_NAME = 'synapsis_sessions';
|
||||
@@ -310,15 +311,38 @@ export async function registerUser(
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
||||
|
||||
const [user] = await db.insert(users).values({
|
||||
did,
|
||||
handle: handle.toLowerCase(),
|
||||
email: email.toLowerCase(),
|
||||
passwordHash,
|
||||
displayName: registrationDisplayName(handle, email, displayName),
|
||||
publicKey,
|
||||
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
|
||||
}).returning();
|
||||
const adminEmails = configuredAdminEmails();
|
||||
const adminUsers = adminEmails.length > 0
|
||||
? (await db.query.users.findMany({
|
||||
where: { email: { in: adminEmails } },
|
||||
})).filter((admin) => !admin.isSuspended)
|
||||
: [];
|
||||
|
||||
const user = await db.transaction(async (tx) => {
|
||||
const [createdUser] = await tx.insert(users).values({
|
||||
did,
|
||||
handle: handle.toLowerCase(),
|
||||
email: email.toLowerCase(),
|
||||
passwordHash,
|
||||
displayName: registrationDisplayName(handle, email, displayName),
|
||||
publicKey,
|
||||
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
|
||||
followingCount: adminUsers.length,
|
||||
}).returning();
|
||||
|
||||
if (adminUsers.length > 0) {
|
||||
await tx.insert(follows).values(adminUsers.map((admin) => ({
|
||||
followerId: createdUser.id,
|
||||
followingId: admin.id,
|
||||
})));
|
||||
|
||||
await tx.update(users)
|
||||
.set({ followersCount: sql`${users.followersCount} + 1` })
|
||||
.where(inArray(users.id, adminUsers.map((admin) => admin.id)));
|
||||
}
|
||||
|
||||
return createdUser;
|
||||
});
|
||||
|
||||
await upsertHandleEntries([{
|
||||
handle: user.handle,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const users = { id: 'users.id', followersCount: 'users.followersCount' };
|
||||
const follows = Symbol('follows');
|
||||
const returning = vi.fn();
|
||||
const insertValues = vi.fn(() => ({ returning }));
|
||||
const insert = vi.fn(() => ({ values: insertValues }));
|
||||
const where = vi.fn();
|
||||
const set = vi.fn(() => ({ where }));
|
||||
const update = vi.fn(() => ({ set }));
|
||||
const tx = { insert, update };
|
||||
|
||||
return {
|
||||
users,
|
||||
follows,
|
||||
returning,
|
||||
insertValues,
|
||||
insert,
|
||||
where,
|
||||
set,
|
||||
update,
|
||||
tx,
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx)),
|
||||
upsertHandleEntries: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/db', () => ({
|
||||
db: {
|
||||
query: {
|
||||
users: {
|
||||
findFirst: mocks.findFirst,
|
||||
findMany: mocks.findMany,
|
||||
},
|
||||
},
|
||||
transaction: mocks.transaction,
|
||||
},
|
||||
users: mocks.users,
|
||||
follows: mocks.follows,
|
||||
sessions: {},
|
||||
}));
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn(),
|
||||
inArray: vi.fn(() => 'admin ids'),
|
||||
sql: vi.fn(() => 'increment followers'),
|
||||
}));
|
||||
|
||||
vi.mock('bcryptjs', () => ({
|
||||
default: {
|
||||
hash: vi.fn(async () => 'password hash'),
|
||||
compare: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/crypto/keys', () => ({
|
||||
generateKeyPair: vi.fn(async () => ({ publicKey: 'public key', privateKey: 'private key' })),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/crypto/did-key', () => ({
|
||||
didKeyMatchesPublicKey: vi.fn(),
|
||||
generateDID: vi.fn(() => 'did:key:new-user'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/crypto/private-key', () => ({
|
||||
encryptPrivateKey: vi.fn(() => ({ encrypted: 'encrypted', salt: 'salt', iv: 'iv' })),
|
||||
serializeEncryptedKey: vi.fn(() => 'serialized private key'),
|
||||
isEncryptedPrivateKeyStored: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/federation/handles', () => ({
|
||||
upsertHandleEntries: mocks.upsertHandleEntries,
|
||||
}));
|
||||
|
||||
import { registerUser } from './index';
|
||||
|
||||
describe('registerUser', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv('ADMIN_EMAILS', 'admin@example.com, second-admin@example.com');
|
||||
vi.stubEnv('NEXT_PUBLIC_NODE_DOMAIN', 'example.social');
|
||||
mocks.findFirst.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('atomically follows every existing server admin for a new account', async () => {
|
||||
const admins = [
|
||||
{ id: 'admin-1', email: 'admin@example.com', isSuspended: false },
|
||||
{ id: 'admin-2', email: 'second-admin@example.com', isSuspended: false },
|
||||
];
|
||||
const createdUser = { id: 'user-1', handle: 'newuser', did: 'did:key:new-user' };
|
||||
mocks.findMany.mockResolvedValue(admins);
|
||||
mocks.returning.mockResolvedValue([createdUser]);
|
||||
|
||||
await registerUser('NewUser', 'new@example.com', 'password123');
|
||||
|
||||
expect(mocks.transaction).toHaveBeenCalledOnce();
|
||||
expect(mocks.insertValues).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
handle: 'newuser',
|
||||
followingCount: 2,
|
||||
}));
|
||||
expect(mocks.insertValues).toHaveBeenNthCalledWith(2, [
|
||||
{ followerId: 'user-1', followingId: 'admin-1' },
|
||||
{ followerId: 'user-1', followingId: 'admin-2' },
|
||||
]);
|
||||
expect(mocks.set).toHaveBeenCalledWith({ followersCount: 'increment followers' });
|
||||
expect(mocks.upsertHandleEntries).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not follow suspended admins', async () => {
|
||||
mocks.findMany.mockResolvedValue([
|
||||
{ id: 'admin-1', email: 'admin@example.com', isSuspended: true },
|
||||
]);
|
||||
mocks.returning.mockResolvedValue([{ id: 'user-1', handle: 'newuser', did: 'did:key:new-user' }]);
|
||||
|
||||
await registerUser('newuser', 'new@example.com', 'password123');
|
||||
|
||||
expect(mocks.insertValues).toHaveBeenCalledOnce();
|
||||
expect(mocks.insertValues).toHaveBeenCalledWith(expect.objectContaining({ followingCount: 0 }));
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user