feat(api/mobile): auth sync/me/sign-out + scans + bearer plugin
Connect the React Native app to the admin backend so users and scans flow
into the panel and bans take effect on the device.
- Activate the bearer() plugin in lib/auth.ts so mobile clients can pass
the better-auth session token via Authorization: Bearer header
- Add requireMobileAuth() helper in lib/auth-guard.ts that resolves the
session, re-fetches the user from DB (banned flag is on User, not in
the Session payload) and returns 403 with banned/bannedReason for
banned accounts
- Extend CORS in middleware.ts to allow POST + Authorization header on
/api/mobile/* (preflight was failing before)
- New routes:
POST /api/mobile/auth/sync — passwordless mobile auth via
deterministic password derived from sha256(email + deviceId + pepper).
Tries signIn first, falls back to signUp on USER_NOT_FOUND. Returns
409 when the email exists with a different deviceId.
GET /api/mobile/auth/me — current user enriched with
banned/bannedReason/role/xp/level
POST /api/mobile/auth/sign-out — best-effort session revocation
POST /api/mobile/scans — create a scan, resolves diseaseSlug
to diseaseId, never accepts an imageUrl from the device (V1 keeps
photos local-only)
GET /api/mobile/scans — own scans, 50 most recent
Validated end-to-end via curl: signUp → me → repeat sync (idempotent) →
post scan → ban via DB → me reflects banned: true → POST scans returns
403 + banned/bannedReason.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4d9c1a0a28
commit
792e969c00
48
vineye-admin/app/api/mobile/auth/me/route.ts
Normal file
48
vineye-admin/app/api/mobile/auth/me/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const CORS_HEADERS = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||||
|
"X-API-Version": "1.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
|
if (!session) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Unauthorized" },
|
||||||
|
{ status: 401, headers: CORS_HEADERS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
xp: true,
|
||||||
|
level: true,
|
||||||
|
banned: true,
|
||||||
|
bannedReason: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "User not found" },
|
||||||
|
{ status: 404, headers: CORS_HEADERS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ user }, { headers: CORS_HEADERS });
|
||||||
|
}
|
||||||
22
vineye-admin/app/api/mobile/auth/sign-out/route.ts
Normal file
22
vineye-admin/app/api/mobile/auth/sign-out/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
const CORS_HEADERS = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||||
|
"X-API-Version": "1.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
await auth.api.signOut({ headers: request.headers });
|
||||||
|
} catch {
|
||||||
|
// best-effort: even if revocation fails the mobile clears its token
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||||
|
}
|
||||||
138
vineye-admin/app/api/mobile/auth/sync/route.ts
Normal file
138
vineye-admin/app/api/mobile/auth/sync/route.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import { APIError } from "better-auth/api";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { mobileAuthSyncSchema } from "@/lib/validations";
|
||||||
|
|
||||||
|
const CORS_HEADERS = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||||
|
"X-API-Version": "1.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PASSWORD_PEPPER =
|
||||||
|
process.env.MOBILE_AUTH_PEPPER ||
|
||||||
|
process.env.BETTER_AUTH_SECRET ||
|
||||||
|
"vineye-mobile-pepper-v1";
|
||||||
|
|
||||||
|
function derivePassword(email: string, deviceId: string | null | undefined) {
|
||||||
|
// Deterministic per email+deviceId+pepper. The password never leaves the
|
||||||
|
// server. If deviceId is missing we fall back to the email so that the
|
||||||
|
// same user can re-login on the same email even without a stable id.
|
||||||
|
const seed = `${email}::${deviceId || "no-device"}::${PASSWORD_PEPPER}`;
|
||||||
|
return createHash("sha256").update(seed).digest("hex").slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Invalid JSON" },
|
||||||
|
{ status: 400, headers: CORS_HEADERS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = mobileAuthSyncSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Validation failed", details: parsed.error.flatten() },
|
||||||
|
{ status: 400, headers: CORS_HEADERS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, email, deviceId } = parsed.data;
|
||||||
|
const password = derivePassword(email, deviceId);
|
||||||
|
|
||||||
|
// Try sign-in first. If the user does not exist (or password mismatch
|
||||||
|
// because the deviceId changed) we fall through to sign-up.
|
||||||
|
let token: string | null = null;
|
||||||
|
let userId: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await auth.api.signInEmail({
|
||||||
|
body: { email, password },
|
||||||
|
asResponse: true,
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
token = res.headers.get("set-auth-token");
|
||||||
|
const data = (await res.json()) as { user?: { id?: string } };
|
||||||
|
userId = data.user?.id ?? null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Most likely INVALID_PASSWORD or USER_NOT_FOUND — let sign-up handle.
|
||||||
|
if (!(err instanceof APIError)) {
|
||||||
|
console.error("[mobile/auth/sync] signIn unexpected error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const res = await auth.api.signUpEmail({
|
||||||
|
body: { name, email, password },
|
||||||
|
asResponse: true,
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
token = res.headers.get("set-auth-token");
|
||||||
|
const data = (await res.json()) as { user?: { id?: string } };
|
||||||
|
userId = data.user?.id ?? null;
|
||||||
|
} else {
|
||||||
|
// 422 USER_ALREADY_EXISTS on a different password = deviceId changed.
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Account exists on another device. Reset the app or use the same device.",
|
||||||
|
},
|
||||||
|
{ status: 409, headers: CORS_HEADERS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[mobile/auth/sync] signUp failed:", err);
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Could not create account" },
|
||||||
|
{ status: 500, headers: CORS_HEADERS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || !userId) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Authentication failed" },
|
||||||
|
{ status: 500, headers: CORS_HEADERS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch the user with the fields the mobile app needs.
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
xp: true,
|
||||||
|
level: true,
|
||||||
|
banned: true,
|
||||||
|
bannedReason: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "User not found after sign-in" },
|
||||||
|
{ status: 500, headers: CORS_HEADERS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banned users get a token but the mobile app will see banned: true and
|
||||||
|
// show the BannedModal at boot.
|
||||||
|
return Response.json({ token, user }, { status: 200, headers: CORS_HEADERS });
|
||||||
|
}
|
||||||
107
vineye-admin/app/api/mobile/scans/route.ts
Normal file
107
vineye-admin/app/api/mobile/scans/route.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireMobileAuth } from "@/lib/auth-guard";
|
||||||
|
import { mobileScanCreateSchema } from "@/lib/validations";
|
||||||
|
|
||||||
|
const CORS_HEADERS = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||||
|
"X-API-Version": "1.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const guard = await requireMobileAuth(request);
|
||||||
|
if ("error" in guard) {
|
||||||
|
return Response.json(
|
||||||
|
"banned" in guard
|
||||||
|
? { error: guard.error, banned: true, bannedReason: guard.bannedReason }
|
||||||
|
: { error: guard.error },
|
||||||
|
{ status: guard.status, headers: CORS_HEADERS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Invalid JSON" },
|
||||||
|
{ status: 400, headers: CORS_HEADERS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = mobileScanCreateSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Validation failed", details: parsed.error.flatten() },
|
||||||
|
{ status: 400, headers: CORS_HEADERS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { confidence, diseaseSlug, latitude, longitude, deviceId } =
|
||||||
|
parsed.data;
|
||||||
|
|
||||||
|
let diseaseId: string | null = null;
|
||||||
|
if (diseaseSlug) {
|
||||||
|
const disease = await prisma.disease.findUnique({
|
||||||
|
where: { slug: diseaseSlug },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
diseaseId = disease?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scan = await prisma.scan.create({
|
||||||
|
data: {
|
||||||
|
userId: guard.user.id,
|
||||||
|
confidence,
|
||||||
|
diseaseId,
|
||||||
|
latitude: latitude ?? null,
|
||||||
|
longitude: longitude ?? null,
|
||||||
|
deviceId: deviceId ?? null,
|
||||||
|
imageUrl: null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
confidence: true,
|
||||||
|
latitude: true,
|
||||||
|
longitude: true,
|
||||||
|
createdAt: true,
|
||||||
|
disease: { select: { slug: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ scan }, { status: 201, headers: CORS_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const guard = await requireMobileAuth(request);
|
||||||
|
if ("error" in guard) {
|
||||||
|
return Response.json(
|
||||||
|
"banned" in guard
|
||||||
|
? { error: guard.error, banned: true, bannedReason: guard.bannedReason }
|
||||||
|
: { error: guard.error },
|
||||||
|
{ status: guard.status, headers: CORS_HEADERS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scans = await prisma.scan.findMany({
|
||||||
|
where: { userId: guard.user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
confidence: true,
|
||||||
|
latitude: true,
|
||||||
|
longitude: true,
|
||||||
|
createdAt: true,
|
||||||
|
disease: { select: { slug: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ scans }, { headers: CORS_HEADERS });
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
export async function getSession() {
|
export async function getSession() {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
|
|
@ -26,3 +28,38 @@ export async function requireAdmin() {
|
||||||
}
|
}
|
||||||
return { session } as const;
|
return { session } as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile auth guard — reads Authorization: Bearer <session-token> via the
|
||||||
|
// bearer() plugin. Also blocks banned users (banned flag is on User, not
|
||||||
|
// on Session — we re-fetch from DB).
|
||||||
|
export async function requireMobileAuth(request: NextRequest) {
|
||||||
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
|
if (!session) {
|
||||||
|
return { error: "Unauthorized", status: 401 } as const;
|
||||||
|
}
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
xp: true,
|
||||||
|
level: true,
|
||||||
|
banned: true,
|
||||||
|
bannedReason: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return { error: "Unauthorized", status: 401 } as const;
|
||||||
|
}
|
||||||
|
if (user.banned) {
|
||||||
|
return {
|
||||||
|
error: "Forbidden",
|
||||||
|
status: 403,
|
||||||
|
banned: true,
|
||||||
|
bannedReason: user.bannedReason,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
return { session, user } as const;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
|
import { bearer } from "better-auth/plugins";
|
||||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
|
@ -24,6 +25,7 @@ export const auth = betterAuth({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
plugins: [bearer()],
|
||||||
trustedOrigins: [
|
trustedOrigins: [
|
||||||
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,24 @@ export const scanSchema = z.object({
|
||||||
deviceId: z.string().optional().nullable(),
|
deviceId: z.string().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mobile-specific schemas
|
||||||
|
export const mobileAuthSyncSchema = z.object({
|
||||||
|
name: z.string().min(2).max(50).trim(),
|
||||||
|
email: z.string().email().max(255).toLowerCase().trim(),
|
||||||
|
deviceId: z.string().max(128).trim().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mobileScanCreateSchema = z.object({
|
||||||
|
confidence: z.number().min(0).max(1),
|
||||||
|
diseaseSlug: z.string().max(100).trim().optional().nullable(),
|
||||||
|
latitude: z.number().min(-90).max(90).optional().nullable(),
|
||||||
|
longitude: z.number().min(-180).max(180).optional().nullable(),
|
||||||
|
deviceId: z.string().max(128).trim().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
export type DiseaseInput = z.infer<typeof diseaseSchema>;
|
export type DiseaseInput = z.infer<typeof diseaseSchema>;
|
||||||
export type GuideInput = z.infer<typeof guideSchema>;
|
export type GuideInput = z.infer<typeof guideSchema>;
|
||||||
export type AlertInput = z.infer<typeof alertSchema>;
|
export type AlertInput = z.infer<typeof alertSchema>;
|
||||||
export type ScanInput = z.infer<typeof scanSchema>;
|
export type ScanInput = z.infer<typeof scanSchema>;
|
||||||
|
export type MobileAuthSyncInput = z.infer<typeof mobileAuthSyncSchema>;
|
||||||
|
export type MobileScanCreateInput = z.infer<typeof mobileScanCreateSchema>;
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
const CORS_HEADERS = {
|
const CORS_HEADERS = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||||
"Access-Control-Allow-Headers": "Content-Type, X-API-Version",
|
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-API-Version",
|
||||||
"X-API-Version": "1.0",
|
"X-API-Version": "1.0",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue