| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245 |
- import { NextResponse } from 'next/server'
- import { runAsActor } from '@/lib/realtime/actor.server'
- import { ZodError } from 'zod'
- import { auth } from './auth'
- import { db } from './db'
- import { hasAllPermissions, type PermissionInput } from './permissions'
- import { rateLimit } from './rate-limit'
- import { DocumentLockedError } from './document-lock'
- import { sessionOrganizationScope } from './session-scope'
- /**
- * Request wrapper for the token-authenticated API the technician app talks to.
- *
- * Deliberately not a copy of `withAuth`. That one serves server actions: it
- * reads a cookie, returns `{ success, data }` and lets the error text through
- * to a caller that is our own UI. This one faces a client we do not control on
- * a device we do not own, so it authenticates from a header, answers in HTTP
- * status codes, and never lets an internal message reach the wire.
- *
- * What it does NOT do is reimplement session handling. The bearer plugin is
- * registered in `./auth`, so `auth.api.getSession` resolves an
- * `Authorization: Bearer <token>` header through Better Auth's own machinery.
- * Querying the session table directly would work right up until a session is
- * revoked, a password changes, or a token is rotated, and then it would keep
- * on working, which is the bug.
- */
- export type ApiAuthContext = {
- userId: string
- organizationId: string
- role: string
- isSuperAdmin: boolean
- isAdmin: boolean
- /**
- * Every technician row this user owns in the active organization. A person
- * can hold more than one (a shop that lanes its board by speciality gives
- * the same mechanic a row per lane), so jobs are always looked up with
- * `in: technicianIds`, never by a single id.
- */
- technicianIds: string[]
- }
- type WithApiAuthOptions = {
- requiredPermissions?: PermissionInput[]
- /**
- * Refuse the request unless the user is an active technician in the active
- * organization. The technician app has no screen that means anything for an
- * office user, and an empty job list would read as "no work today" rather
- * than "you are not set up as a technician".
- */
- requireTechnician?: boolean
- /** Per-IP-and-path budget. Defaults to the shared 30-per-minute. */
- rateLimit?: { limit?: number; windowMs?: number }
- }
- /** Machine-readable codes so the app can branch without parsing prose. */
- export type ApiErrorCode =
- | 'unauthorized'
- | 'forbidden'
- | 'not_technician'
- | 'no_organization'
- | 'not_found'
- | 'invalid_request'
- | 'conflict'
- | 'document_locked'
- | 'server_error'
- export function apiError(status: number, code: ApiErrorCode, message: string, extra?: unknown) {
- return NextResponse.json(
- { error: { code, message, ...(extra ? { details: extra } : {}) } },
- { status }
- )
- }
- export function apiOk<T>(data: T, status = 200) {
- return NextResponse.json({ data }, { status })
- }
- export async function withApiAuth(
- request: Request,
- handler: (ctx: ApiAuthContext) => Promise<NextResponse>,
- options: WithApiAuthOptions = {}
- ): Promise<NextResponse> {
- // Cheap rejections first: a flood of unauthenticated requests should cost a
- // map lookup, not a database round trip.
- //
- // Note this limiter is per-process and in-memory. Behind more than one app
- // container the effective budget is the limit times the container count.
- // Good enough to blunt a runaway client; not a defence against a determined
- // attacker, which would need a shared store.
- const limited = rateLimit(request, options.rateLimit)
- if (limited) return limited
- const authHeader = request.headers.get('authorization')
- if (!authHeader?.toLowerCase().startsWith('bearer ')) {
- return apiError(401, 'unauthorized', 'Missing bearer token.')
- }
- let session: Awaited<ReturnType<typeof auth.api.getSession>>
- try {
- session = await auth.api.getSession({ headers: request.headers })
- } catch {
- // A malformed token is a client mistake, not a server fault.
- return apiError(401, 'unauthorized', 'Invalid or expired token.')
- }
- if (!session?.user?.id) {
- return apiError(401, 'unauthorized', 'Invalid or expired token.')
- }
- const userId = session.user.id
- // A session minted from a setup code or a one-time code is bound to the
- // workshop that minted it (lib/session-scope.ts). It is that workshop and
- // nothing else, and it carries none of the account's platform rights.
- const scope = sessionOrganizationScope(session)
- const user = await db.user.findUnique({
- where: { id: userId },
- select: { isSuperAdmin: true },
- })
- const isSuperAdmin = !scope && (user?.isSuperAdmin ?? false)
- // The app names which workshop it is talking to, because a user may belong
- // to several. The header only selects; it never grants. Membership is what
- // decides, and an id the user is not a member of falls through to their
- // default rather than erroring, so a stale header cannot lock the app out.
- //
- // A bound session does not get to select. Its workshop is the only answer,
- // and if the person has left it there is nothing to fall back to.
- const requestedOrgId = scope ?? request.headers.get('x-org-id')
- const memberSelect = {
- organizationId: true,
- role: true,
- roleId: true,
- customRole: {
- select: { isAdmin: true, permissions: { select: { action: true, subject: true } } },
- },
- } as const
- let membership = requestedOrgId
- ? await db.organizationMember.findFirst({
- where: { userId, organizationId: requestedOrgId },
- select: memberSelect,
- })
- : null
- // 401 rather than 403: this session can never work again, and the app
- // treats a 401 as the cue to sign in afresh.
- if (!membership && scope) {
- return apiError(401, 'unauthorized', 'You are no longer a member of this workshop.')
- }
- if (!membership) {
- membership = await db.organizationMember.findFirst({
- where: { userId },
- select: memberSelect,
- })
- }
- if (!membership?.organizationId) {
- return apiError(403, 'no_organization', 'This account is not a member of any workshop.')
- }
- const organizationId = membership.organizationId
- const isOwnerOrAdmin = membership.role === 'owner' || membership.role === 'admin'
- const roleIsAdmin = membership.customRole?.isAdmin === true
- // Mirrors `withAuth` exactly, and must continue to: an API that disagrees
- // with the web app about who may do what is a hole shaped like a client.
- if (!isSuperAdmin && options.requiredPermissions?.length) {
- if (!isOwnerOrAdmin && !roleIsAdmin) {
- const granted = membership.customRole?.permissions ?? []
- if (!hasAllPermissions(granted, options.requiredPermissions)) {
- return apiError(403, 'forbidden', 'Your role does not allow this.')
- }
- }
- }
- const technicians = await db.technician.findMany({
- where: { organizationId, userId, isActive: true },
- select: { id: true },
- })
- const technicianIds = technicians.map((t) => t.id)
- if (options.requireTechnician && technicianIds.length === 0) {
- return apiError(
- 403,
- 'not_technician',
- 'This account is not set up as a technician in this workshop. Ask the workshop to add you.'
- )
- }
- try {
- // Inside the actor, so a write from the phone tells the desk's screens
- // that it came from the app and who made it (lib/realtime).
- return await runAsActor({ userId, name: null, source: 'app' }, () =>
- handler({
- userId,
- organizationId,
- role: isSuperAdmin ? 'super_admin' : (membership.role ?? 'member'),
- isSuperAdmin,
- isAdmin: isSuperAdmin || isOwnerOrAdmin || roleIsAdmin,
- technicianIds,
- })
- )
- } catch (err) {
- // Zod messages describe the caller's own payload, so they are safe and
- // genuinely useful to return. Everything else is ours and stays here.
- // A locked document is a refusal, not a fault: the technician needs to be
- // told the job is locked (and to see the office about it), not to retry a
- // request that can never succeed. The message names the rule that applied
- // and is written for the caller, so it is safe to return.
- if (err instanceof DocumentLockedError) {
- return apiError(409, 'document_locked', err.message)
- }
- if (err instanceof ZodError) {
- return apiError(
- 400,
- 'invalid_request',
- 'The request body was not valid.',
- err.issues.map((e) => ({ path: e.path.join('.'), message: e.message }))
- )
- }
- if (err instanceof SyntaxError) {
- return apiError(400, 'invalid_request', 'The request body was not valid JSON.')
- }
- console.error('[api]', request.method, new URL(request.url).pathname, err)
- // In development the app is the fastest place to read the failure: the
- // person holding the phone is the person who can fix it, and making them
- // go and find the server console for every 500 is a slow way to debug a
- // client that runs somewhere else. Production says nothing, because an
- // internal message is a map of the server drawn for whoever asked.
- if (process.env.NODE_ENV !== 'production') {
- return apiError(
- 500,
- 'server_error',
- err instanceof Error ? err.message : 'Something went wrong. Try again.'
- )
- }
- return apiError(500, 'server_error', 'Something went wrong. Try again.')
- }
- }
|