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:
2026-07-15 11:53:46 -07:00
committed by Hop
parent a1841c51a7
commit 0e64b83a99
5 changed files with 194 additions and 23 deletions
+17
View File
@@ -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);
});
});
+16
View File
@@ -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
View File
@@ -1,22 +1,12 @@
import { requireAuth } from '@/lib/auth'; import { requireAuth } from '@/lib/auth';
import { users } from '@/db'; import { users } from '@/db';
import { isConfiguredAdminEmail } from '@/lib/auth/admin-config';
type User = typeof users.$inferSelect; 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) => { export const isAdminUser = (user: User | null | undefined) => {
if (!user) return false; if (!user) return false;
if (user.email && adminEmails.length > 0 && adminEmails.includes(user.email.toLowerCase())) { return isConfiguredAdminEmail(user.email);
return true;
}
return false;
}; };
export async function requireAdmin(): Promise<User> { export async function requireAdmin(): Promise<User> {
+35 -11
View File
@@ -2,8 +2,8 @@
* Authentication Utilities * Authentication Utilities
*/ */
import { db, users, sessions } from '@/db'; import { db, follows, users, sessions } from '@/db';
import { eq, inArray } from 'drizzle-orm'; import { eq, inArray, sql } from 'drizzle-orm';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { generateKeyPair } from '@/lib/crypto/keys'; import { generateKeyPair } from '@/lib/crypto/keys';
@@ -12,6 +12,7 @@ import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-k
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { upsertHandleEntries } from '@/lib/federation/handles'; import { upsertHandleEntries } from '@/lib/federation/handles';
import { registrationDisplayName } from '@/lib/auth/display-name'; import { registrationDisplayName } from '@/lib/auth/display-name';
import { configuredAdminEmails } from '@/lib/auth/admin-config';
const ACTIVE_SESSION_COOKIE_NAME = 'synapsis_session'; const ACTIVE_SESSION_COOKIE_NAME = 'synapsis_session';
const SESSION_COOKIE_NAME = 'synapsis_sessions'; 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 nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const [user] = await db.insert(users).values({ const adminEmails = configuredAdminEmails();
did, const adminUsers = adminEmails.length > 0
handle: handle.toLowerCase(), ? (await db.query.users.findMany({
email: email.toLowerCase(), where: { email: { in: adminEmails } },
passwordHash, })).filter((admin) => !admin.isSuspended)
displayName: registrationDisplayName(handle, email, displayName), : [];
publicKey,
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey), const user = await db.transaction(async (tx) => {
}).returning(); 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([{ await upsertHandleEntries([{
handle: user.handle, handle: user.handle,
+124
View File
@@ -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();
});
});