feat: let guests publish a CommunityRule and claim it on this browser later

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>
This commit is contained in:
adilallo
2026-08-26 11:01:10 -06:00
co-authored by Cursor
parent bf4020005a
commit 950b3224ac
23 changed files with 689 additions and 31 deletions
+109
View File
@@ -0,0 +1,109 @@
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;
}
}