Fix NSFW settings by signing client mutations and handling locked identities

Hop-State: A_06FP8RGGWKVFCFAHNJBQWT0
Hop-Proposal: R_06FP8RG166W8E071QG1PYWG
Hop-Task: T_06FP8QVS84N3BR04V6F3GJG
Hop-Attempt: AT_06FP8QVS87X77VCAYKNHKF0
This commit is contained in:
2026-07-14 23:06:16 -07:00
committed by Hop
parent 1427df4bdc
commit 1db2933758
5 changed files with 159 additions and 15 deletions
+11 -4
View File
@@ -7,7 +7,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth';
import { requireSignedAction, SignedActionError } from '@/lib/auth/verify-signature';
import { z } from 'zod';
const updateSchema = z.object({
@@ -22,9 +22,12 @@ const updateSchema = z.object({
*/
export async function POST(request: NextRequest) {
try {
const user = await requireAuth();
const body = await request.json();
const { isNsfw } = updateSchema.parse(body);
const signedAction = await request.json();
if (signedAction.action !== 'update_account_nsfw') {
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
}
const user = await requireSignedAction(signedAction);
const { isNsfw } = updateSchema.parse(signedAction.data);
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 500 });
@@ -45,9 +48,13 @@ export async function POST(request: NextRequest) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
if (error instanceof SignedActionError) {
return NextResponse.json({ error: 'Your identity could not be verified. Please unlock it and try again.' }, { status: 403 });
}
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('Account NSFW settings update error:', error);
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
}
}
+92
View File
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { POST as updateViewingPreference } from './route';
import { POST as updateAccountPreference } from '../account-nsfw/route';
import { requireSignedAction, SignedActionError } from '@/lib/auth/verify-signature';
import { db } from '@/db';
vi.mock('@/lib/auth', () => ({ requireAuth: vi.fn() }));
vi.mock('@/lib/auth/verify-signature', () => {
class MockSignedActionError extends Error {}
return {
requireSignedAction: vi.fn(),
SignedActionError: MockSignedActionError,
};
});
vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => ({})) }));
vi.mock('@/db', () => {
const where = vi.fn().mockResolvedValue(undefined);
const set = vi.fn(() => ({ where }));
const update = vi.fn(() => ({ set }));
return {
db: { update },
users: { id: 'id' },
};
});
const signedAction = (action: string, data: Record<string, unknown>) => ({
action,
data,
did: 'did:synapsis:test',
handle: 'tester',
ts: Date.now(),
nonce: 'nonce',
sig: 'signature',
});
const request = (body: unknown) => new Request('http://localhost/api/settings/nsfw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
describe('NSFW settings mutations', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireSignedAction).mockResolvedValue({
id: 'user-id',
ageVerifiedAt: new Date('2026-01-01T00:00:00.000Z'),
} as never);
});
it('accepts a signed viewing-preference update', async () => {
const payload = signedAction('update_nsfw_settings', { nsfwEnabled: true });
const response = await updateViewingPreference(request(payload) as never);
expect(response.status).toBe(200);
expect(requireSignedAction).toHaveBeenCalledWith(payload);
expect(db?.update).toHaveBeenCalled();
});
it('accepts a signed account-level update', async () => {
const payload = signedAction('update_account_nsfw', { isNsfw: true });
const response = await updateAccountPreference(request(payload) as never);
expect(response.status).toBe(200);
expect(requireSignedAction).toHaveBeenCalledWith(payload);
expect(await response.json()).toMatchObject({ success: true, isNsfw: true });
});
it('rejects the old unsigned request shape', async () => {
const response = await updateViewingPreference(request({ nsfwEnabled: true }) as never);
expect(response.status).toBe(400);
expect(requireSignedAction).not.toHaveBeenCalled();
});
it('returns a useful identity error when verification fails', async () => {
vi.mocked(requireSignedAction).mockRejectedValue(new SignedActionError('INVALID_SIGNATURE'));
const payload = signedAction('update_nsfw_settings', { nsfwEnabled: true });
const response = await updateViewingPreference(request(payload) as never);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
error: 'Your identity could not be verified. Please unlock it and try again.',
});
});
});
+8 -1
View File
@@ -9,7 +9,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth'; // kept for GET
import { requireSignedAction } from '@/lib/auth/verify-signature';
import { requireSignedAction, SignedActionError } from '@/lib/auth/verify-signature';
import { z } from 'zod';
const updateSchema = z.object({
@@ -48,6 +48,9 @@ export async function GET() {
export async function POST(request: NextRequest) {
try {
const signedAction = await request.json();
if (signedAction.action !== 'update_nsfw_settings') {
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
}
const user = await requireSignedAction(signedAction);
// Trust signed payload data
@@ -100,9 +103,13 @@ export async function POST(request: NextRequest) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
if (error instanceof SignedActionError) {
return NextResponse.json({ error: 'Your identity could not be verified. Please unlock it and try again.' }, { status: 403 });
}
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('NSFW settings update error:', error);
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
}
}