Grapevine_Disease_Detection/vineye-admin/app/api/mobile/auth/sync/route.ts
Yanis 792e969c00 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>
2026-05-01 12:02:45 +02:00

139 lines
4.1 KiB
TypeScript

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 });
}