Add roles and invite models
Additive schema only; no behaviour changes yet. Verified with prisma migrate diff against production: two enums, three new tables, new User columns and foreign keys, and no destructive statements. Role lives on the User row rather than an OWNER_EMAIL env check, because email is nullable and user-editable and so a poor thing to authorize against. The jwt callback will need a per-request lookup for status anyway, so reading role in the same query is free. Invites are bearer tokens in a URL, shared out of band as a link or QR code, because the app has no email capability. claimInvite consumes a use with a single UPDATE guarded on usedCount < maxUses: Prisma cannot compare two columns in a where clause, and one statement means one row lock, so two people redeeming the last use cannot both succeed. SearchCache finally gets its user relation. Every other user-owned model cascades; without it, deleting a user left orphaned rows holding their raw search queries. Verified zero orphans before adding the constraint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,16 @@ datasource db {
|
|||||||
|
|
||||||
// ─── Auth.js Models ──────────────────────────────────────────────
|
// ─── Auth.js Models ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
OWNER
|
||||||
|
MEMBER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserStatus {
|
||||||
|
ACTIVE
|
||||||
|
SUSPENDED
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String?
|
name String?
|
||||||
@@ -19,6 +29,17 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Authorization is on this column, not on an OWNER_EMAIL env check: email is
|
||||||
|
// nullable and user-editable, so it is a poor thing to authorize against.
|
||||||
|
role UserRole @default(MEMBER)
|
||||||
|
status UserStatus @default(ACTIVE)
|
||||||
|
invitedById String?
|
||||||
|
|
||||||
|
// Per-member AI controls. aiDailyBudgetUsd null means "use the deployment default"
|
||||||
|
// when minting that member's gateway key.
|
||||||
|
aiEnabled Boolean @default(true)
|
||||||
|
aiDailyBudgetUsd Float?
|
||||||
|
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
drinks Drink[]
|
drinks Drink[]
|
||||||
@@ -31,6 +52,62 @@ model User {
|
|||||||
barItems BarItem[]
|
barItems BarItem[]
|
||||||
recipes Recipe[]
|
recipes Recipe[]
|
||||||
flavorProfile FlavorProfile?
|
flavorProfile FlavorProfile?
|
||||||
|
searchCache SearchCache[]
|
||||||
|
|
||||||
|
invitesCreated Invite[] @relation("InviteCreator")
|
||||||
|
redemption InviteRedemption?
|
||||||
|
passwordResets PasswordReset[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Invites ─────────────────────────────────────────────────────
|
||||||
|
// Signup is invite-only. There is no email capability in this app, so an invite is
|
||||||
|
// a bearer token in a URL that the owner shares out of band (link or QR code).
|
||||||
|
|
||||||
|
model Invite {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
token String @unique // randomBytes(16).toString("hex")
|
||||||
|
label String? // free text so the owner remembers who it was for
|
||||||
|
createdById String
|
||||||
|
maxUses Int @default(1)
|
||||||
|
usedCount Int @default(0)
|
||||||
|
expiresAt DateTime?
|
||||||
|
revokedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
createdBy User @relation("InviteCreator", fields: [createdById], references: [id], onDelete: Cascade)
|
||||||
|
redemptions InviteRedemption[]
|
||||||
|
|
||||||
|
@@index([createdById])
|
||||||
|
}
|
||||||
|
|
||||||
|
model InviteRedemption {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
inviteId String
|
||||||
|
userId String @unique // a user is created by at most one invite
|
||||||
|
email String?
|
||||||
|
provider String // "credentials" | "oauth"
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
invite Invite @relation(fields: [inviteId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([inviteId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner-generated, single-use password reset. Delivered out of band like an invite,
|
||||||
|
// because there is no email infrastructure to send one.
|
||||||
|
model PasswordReset {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
token String @unique
|
||||||
|
userId String
|
||||||
|
expiresAt DateTime
|
||||||
|
usedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
@@ -230,6 +307,10 @@ model SearchCache {
|
|||||||
provider String // "switchboard"
|
provider String // "switchboard"
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Every other user-owned model cascades; without this, deleting a user left
|
||||||
|
// orphaned rows holding their raw search queries and AI results.
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([userId, queryHash, provider])
|
@@unique([userId, queryHash, provider])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/lib/authz.ts
Normal file
40
src/lib/authz.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import type { Session } from "next-auth"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorization helpers for route handlers.
|
||||||
|
*
|
||||||
|
* Each returns either the session or a Response to return directly:
|
||||||
|
*
|
||||||
|
* const result = await requireOwner()
|
||||||
|
* if (result instanceof NextResponse) return result
|
||||||
|
* // result is a Session from here on
|
||||||
|
*
|
||||||
|
* Role lives on the User row rather than an OWNER_EMAIL env check, because email is
|
||||||
|
* nullable and user-editable and so a poor thing to authorize against.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function isOwner(session: Session | null): boolean {
|
||||||
|
return session?.user?.role === "OWNER"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireUser(): Promise<Session | NextResponse> {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireOwner(): Promise<Session | NextResponse> {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
if (!isOwner(session)) {
|
||||||
|
// 404 rather than 403 so the existence of owner-only endpoints is not confirmed.
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
}
|
||||||
88
src/lib/invites.ts
Normal file
88
src/lib/invites.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { randomBytes } from "crypto"
|
||||||
|
import { Prisma } from "@prisma/client"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/** Cookie carrying the invite token between /invite/<token> and signup. */
|
||||||
|
export const INVITE_COOKIE = "dt_invite"
|
||||||
|
|
||||||
|
/** How long an invite token stays usable in the browser after the link is opened. */
|
||||||
|
export const INVITE_COOKIE_MAX_AGE = 30 * 60
|
||||||
|
|
||||||
|
export class InviteError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number = 400
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = "InviteError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 128 bits, same shape as the share-list slugs in api/shared-lists/route.ts. */
|
||||||
|
export function generateInviteToken(): string {
|
||||||
|
return randomBytes(16).toString("hex")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inviteUrl(token: string, origin: string): string {
|
||||||
|
return `${origin.replace(/\/$/, "")}/invite/${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InviteState = "valid" | "unknown" | "revoked" | "expired" | "exhausted"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only check, for rendering. Never rely on this to admit a signup - between the
|
||||||
|
* check and the write another request can consume the last use. Use claimInvite for that.
|
||||||
|
*/
|
||||||
|
export async function inspectInvite(token: string): Promise<{
|
||||||
|
state: InviteState
|
||||||
|
invite?: { id: string; label: string | null; createdById: string; inviterName: string | null }
|
||||||
|
}> {
|
||||||
|
if (!token || !/^[a-f0-9]{32}$/.test(token)) return { state: "unknown" }
|
||||||
|
|
||||||
|
const invite = await prisma.invite.findUnique({
|
||||||
|
where: { token },
|
||||||
|
include: { createdBy: { select: { name: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!invite) return { state: "unknown" }
|
||||||
|
if (invite.revokedAt) return { state: "revoked" }
|
||||||
|
if (invite.expiresAt && invite.expiresAt <= new Date()) return { state: "expired" }
|
||||||
|
if (invite.usedCount >= invite.maxUses) return { state: "exhausted" }
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: "valid",
|
||||||
|
invite: {
|
||||||
|
id: invite.id,
|
||||||
|
label: invite.label,
|
||||||
|
createdById: invite.createdById,
|
||||||
|
inviterName: invite.createdBy.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically consume one use of an invite. Returns the invite, or null if it was not
|
||||||
|
* claimable (unknown, revoked, expired, or already exhausted).
|
||||||
|
*
|
||||||
|
* The guard has to be a single UPDATE because Prisma's query builder cannot compare
|
||||||
|
* two columns in a `where` (`usedCount < maxUses`). One statement means one row lock,
|
||||||
|
* so two people redeeming the last use of a link cannot both succeed.
|
||||||
|
*
|
||||||
|
* Call inside a transaction alongside the user creation, so that a later failure
|
||||||
|
* (duplicate email, for instance) rolls the consumed use back.
|
||||||
|
*/
|
||||||
|
export async function claimInvite(tx: Prisma.TransactionClient, token: string) {
|
||||||
|
if (!token || !/^[a-f0-9]{32}$/.test(token)) return null
|
||||||
|
|
||||||
|
const claimed = await tx.$executeRaw`
|
||||||
|
UPDATE "Invite"
|
||||||
|
SET "usedCount" = "usedCount" + 1, "updatedAt" = NOW()
|
||||||
|
WHERE "token" = ${token}
|
||||||
|
AND "revokedAt" IS NULL
|
||||||
|
AND ("expiresAt" IS NULL OR "expiresAt" > NOW())
|
||||||
|
AND "usedCount" < "maxUses"
|
||||||
|
`
|
||||||
|
|
||||||
|
if (claimed !== 1) return null
|
||||||
|
return tx.invite.findUnique({ where: { token } })
|
||||||
|
}
|
||||||
14
src/types/next-auth.d.ts
vendored
14
src/types/next-auth.d.ts
vendored
@@ -4,6 +4,20 @@ declare module "next-auth" {
|
|||||||
interface Session {
|
interface Session {
|
||||||
user: {
|
user: {
|
||||||
id: string
|
id: string
|
||||||
|
/**
|
||||||
|
* Populated by the session callback in src/lib/auth.ts from a per-request
|
||||||
|
* lookup. Optional because a token minted before roles existed will not
|
||||||
|
* carry one until it is next refreshed.
|
||||||
|
*/
|
||||||
|
role?: "OWNER" | "MEMBER"
|
||||||
} & DefaultSession["user"]
|
} & DefaultSession["user"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "next-auth/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
id?: string
|
||||||
|
role?: "OWNER" | "MEMBER"
|
||||||
|
status?: "ACTIVE" | "SUSPENDED"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user