import { cookies } from "next/headers"; import { logger } from "../logger"; import { prisma } from "./db"; import { getSessionPepper } from "./env"; import { hashRuleClaimToken } from "./hash"; const GUEST_RULE_CLAIM_COOKIE_NAME = "cr_rule_claim"; const CLAIM_COOKIE_MAX_AGE_SEC = 60 * 60 * 24 * 30; const CLAIM_COOKIE_MAX_TOKENS = 8; const TOKEN_MIN_LEN = 16; const TOKEN_MAX_LEN = 128; function cookieOptions(): { httpOnly: true; secure: boolean; sameSite: "lax"; path: "/"; maxAge: number; } { return { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: CLAIM_COOKIE_MAX_AGE_SEC, }; } function parseClaimTokens(raw: string | undefined): string[] { if (!raw) return []; try { const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return []; const tokens: string[] = []; for (const item of parsed) { if (typeof item !== "string") continue; if (item.length < TOKEN_MIN_LEN || item.length > TOKEN_MAX_LEN) continue; tokens.push(item); } return tokens.slice(-CLAIM_COOKIE_MAX_TOKENS); } catch { return []; } } /** Remember a guest-publish claim secret on this browser (httpOnly). */ export async function issueGuestRuleClaimCookie(token: string): Promise { const store = await cookies(); const existing = parseClaimTokens( store.get(GUEST_RULE_CLAIM_COOKIE_NAME)?.value, ); const next = [ ...existing.filter((item) => item !== token), token, ].slice(-CLAIM_COOKIE_MAX_TOKENS); store.set( GUEST_RULE_CLAIM_COOKIE_NAME, JSON.stringify(next), cookieOptions(), ); } async function clearGuestRuleClaimCookie(): Promise { const store = await cookies(); store.set(GUEST_RULE_CLAIM_COOKIE_NAME, "", { ...cookieOptions(), maxAge: 0, }); } /** * Attach ownerless published rules whose claim secrets are in this browser's * cookie to `userId`, then drop the cookie. Never throws to the caller. */ export async function claimGuestRulesForUser(userId: string): Promise { if (typeof userId !== "string" || userId.trim() === "") return 0; try { const store = await cookies(); const tokens = parseClaimTokens( store.get(GUEST_RULE_CLAIM_COOKIE_NAME)?.value, ); if (tokens.length === 0) return 0; let pepper: string; try { pepper = getSessionPepper(); } catch { return 0; } let claimed = 0; for (const token of tokens) { const claimTokenHash = hashRuleClaimToken(token, pepper); const result = await prisma.publishedRule.updateMany({ where: { claimTokenHash, userId: null }, data: { userId, claimTokenHash: null }, }); claimed += result.count; } await clearGuestRuleClaimCookie(); return claimed; } catch (err) { logger.warn("[guest-rule-claim] failed", err); return 0; } }