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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@ export function hashSessionToken(token: string, pepper: string): string {
|
||||
return sha256Hex(`${pepper}:session:${token}`);
|
||||
}
|
||||
|
||||
/** Guest-publish claim secret; prefix is distinct from session hashes. */
|
||||
export function hashRuleClaimToken(token: string, pepper: string): string {
|
||||
return sha256Hex(`${pepper}:rule-claim:${token}`);
|
||||
}
|
||||
|
||||
export function newSessionToken(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { User } from "@prisma/client";
|
||||
import { logger } from "../logger";
|
||||
import { prisma } from "./db";
|
||||
import { getSessionPepper } from "./env";
|
||||
import { claimGuestRulesForUser } from "./guestRuleClaim";
|
||||
import { hashSessionToken, newSessionToken } from "./hash";
|
||||
|
||||
/**
|
||||
@@ -136,6 +137,12 @@ export async function createSessionForUser(
|
||||
logger.warn("[session] expired-row cleanup failed", err);
|
||||
}
|
||||
|
||||
try {
|
||||
await claimGuestRulesForUser(userId);
|
||||
} catch (err) {
|
||||
logger.warn("[session] guest rule claim failed", err);
|
||||
}
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user