Finalize no longer requires a magic link. Guest rows stay off the catalog until sign-in on the same browser attaches ownership, and the login modal kebab no longer acts as a second close. Co-authored-by: Cursor <cursoragent@cursor.com>
110 lines
2.8 KiB
TypeScript
110 lines
2.8 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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<number> {
|
|
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;
|
|
}
|
|
}
|