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
+13
View File
@@ -139,4 +139,17 @@ describe("Login", () => {
screen.getByRole("link", { name: /back to home/i }),
).toBeInTheDocument();
});
it("does not render a more-options kebab", async () => {
renderWithProviders(
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
<p id="login-modal-heading">Body</p>
</Login>,
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
expect(screen.getByLabelText("Close dialog")).toBeInTheDocument();
expect(screen.queryByLabelText("More options")).not.toBeInTheDocument();
});
});
+1
View File
@@ -111,6 +111,7 @@ describe("legal document pages", () => {
screen.getByText(/There are no advertising or analytics cookies to turn off/),
).toBeInTheDocument();
expect(screen.getByText(/cr_session/)).toBeInTheDocument();
expect(screen.getByText(/cr_rule_claim/)).toBeInTheDocument();
expect(
screen.getAllByRole("link", { name: "Privacy Policy" })[0],
).toHaveAttribute("href", "/privacy");
+126
View File
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const cookieGetMock = vi.fn();
const cookieSetMock = vi.fn();
const updateManyMock = vi.fn();
const getSessionPepperMock = vi.fn();
const hashRuleClaimTokenMock = vi.fn();
vi.mock("next/headers", () => ({
cookies: async () => ({
get: cookieGetMock,
set: cookieSetMock,
}),
}));
vi.mock("../../lib/server/db", () => ({
prisma: {
publishedRule: {
updateMany: (...args: unknown[]) => updateManyMock(...args),
},
},
}));
vi.mock("../../lib/server/env", () => ({
getSessionPepper: () => getSessionPepperMock(),
}));
vi.mock("../../lib/server/hash", () => ({
hashRuleClaimToken: (...args: unknown[]) => hashRuleClaimTokenMock(...args),
}));
vi.mock("../../lib/logger", () => ({
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import {
claimGuestRulesForUser,
issueGuestRuleClaimCookie,
} from "../../lib/server/guestRuleClaim";
beforeEach(() => {
cookieGetMock.mockReset();
cookieSetMock.mockReset();
updateManyMock.mockReset();
getSessionPepperMock.mockReset();
hashRuleClaimTokenMock.mockReset();
getSessionPepperMock.mockReturnValue("pepper");
hashRuleClaimTokenMock.mockImplementation((token: string) => `h-${token}`);
});
describe("issueGuestRuleClaimCookie", () => {
it("stores the token in an httpOnly cookie", async () => {
cookieGetMock.mockReturnValue(undefined);
await issueGuestRuleClaimCookie("token-aaaaaaaaaaaaaaaa");
expect(cookieSetMock).toHaveBeenCalledWith(
"cr_rule_claim",
JSON.stringify(["token-aaaaaaaaaaaaaaaa"]),
expect.objectContaining({
httpOnly: true,
path: "/",
sameSite: "lax",
}),
);
});
it("appends without duplicating", async () => {
cookieGetMock.mockReturnValue({
value: JSON.stringify(["token-aaaaaaaaaaaaaaaa"]),
});
await issueGuestRuleClaimCookie("token-bbbbbbbbbbbbbbbb");
expect(cookieSetMock).toHaveBeenCalledWith(
"cr_rule_claim",
JSON.stringify(["token-aaaaaaaaaaaaaaaa", "token-bbbbbbbbbbbbbbbb"]),
expect.any(Object),
);
});
});
describe("claimGuestRulesForUser", () => {
it("returns 0 when the cookie is empty", async () => {
cookieGetMock.mockReturnValue(undefined);
const claimed = await claimGuestRulesForUser("user-1");
expect(claimed).toBe(0);
expect(updateManyMock).not.toHaveBeenCalled();
});
it("claims matching ownerless rows and clears the cookie", async () => {
cookieGetMock.mockReturnValue({
value: JSON.stringify(["token-aaaaaaaaaaaaaaaa"]),
});
updateManyMock.mockResolvedValueOnce({ count: 1 });
const claimed = await claimGuestRulesForUser("user-1");
expect(claimed).toBe(1);
expect(hashRuleClaimTokenMock).toHaveBeenCalledWith(
"token-aaaaaaaaaaaaaaaa",
"pepper",
);
expect(updateManyMock).toHaveBeenCalledWith({
where: { claimTokenHash: "h-token-aaaaaaaaaaaaaaaa", userId: null },
data: { userId: "user-1", claimTokenHash: null },
});
expect(cookieSetMock).toHaveBeenCalledWith(
"cr_rule_claim",
"",
expect.objectContaining({ maxAge: 0 }),
);
});
it("does not attach a rule that already has an owner", async () => {
cookieGetMock.mockReturnValue({
value: JSON.stringify(["token-aaaaaaaaaaaaaaaa"]),
});
updateManyMock.mockResolvedValueOnce({ count: 0 });
const claimed = await claimGuestRulesForUser("user-1");
expect(claimed).toBe(0);
expect(cookieSetMock).toHaveBeenCalledWith(
"cr_rule_claim",
"",
expect.objectContaining({ maxAge: 0 }),
);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { hashRuleClaimToken, hashSessionToken } from "../../lib/server/hash";
describe("hashRuleClaimToken", () => {
it("is stable for the same token and pepper", () => {
const a = hashRuleClaimToken("secret-token", "pepper");
const b = hashRuleClaimToken("secret-token", "pepper");
expect(a).toBe(b);
expect(a).toMatch(/^[a-f0-9]{64}$/);
});
it("differs from the session hash of the same token", () => {
const claim = hashRuleClaimToken("secret-token", "pepper");
const session = hashSessionToken("secret-token", "pepper");
expect(claim).not.toBe(session);
});
});
@@ -28,11 +28,13 @@ vi.mock("../../../lib/create/lastPublishedRule", () => ({
}));
const emptyState = {} as CreateFlowState;
const signedIn = { id: "user-1", email: "owner@example.com" };
describe("useCreateFlowFinalize", () => {
const router = { push: vi.fn() };
const updateState = vi.fn();
const openLogin = vi.fn();
const onGuestPublished = vi.fn();
beforeEach(() => {
vi.mocked(publishRule).mockReset();
@@ -41,6 +43,7 @@ describe("useCreateFlowFinalize", () => {
router.push.mockReset();
updateState.mockReset();
openLogin.mockReset();
onGuestPublished.mockReset();
});
afterEach(() => {
@@ -61,6 +64,7 @@ describe("useCreateFlowFinalize", () => {
openLogin,
updateState,
loginReturnPath: "/create/final-review",
sessionUser: signedIn,
}),
);
@@ -80,6 +84,99 @@ describe("useCreateFlowFinalize", () => {
});
});
it("does not publish while the session is unresolved", async () => {
const { result } = renderHook(() =>
useCreateFlowFinalize({
state: emptyState,
router,
openLogin,
updateState,
loginReturnPath: "/create/final-review",
sessionUser: undefined,
}),
);
await act(async () => {
await result.current.finalize();
});
expect(publishRule).not.toHaveBeenCalled();
expect(router.push).not.toHaveBeenCalled();
});
it("omits stakeholder emails when a guest publishes with invites in state", async () => {
vi.mocked(publishRule).mockResolvedValue({
ok: true,
id: "guest-rule-id",
title: "Published title",
});
const { result } = renderHook(() =>
useCreateFlowFinalize({
state: {
...emptyState,
stakeholderEmails: ["invitee@example.com"],
},
router,
openLogin,
updateState,
loginReturnPath: "/create/final-review",
sessionUser: null,
onGuestPublished,
}),
);
await act(async () => {
await result.current.finalize();
});
expect(publishRule).toHaveBeenCalledWith({
title: "Published title",
summary: "Published summary",
document: {},
});
expect(onGuestPublished).toHaveBeenCalledWith({ skippedInvites: true });
expect(openLogin).not.toHaveBeenCalled();
expect(router.push).toHaveBeenCalledWith(
`/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`,
);
});
it("hints to claim later when a guest publishes without stakeholder emails", async () => {
vi.mocked(publishRule).mockResolvedValue({
ok: true,
id: "guest-rule-id",
title: "Published title",
});
const { result } = renderHook(() =>
useCreateFlowFinalize({
state: emptyState,
router,
openLogin,
updateState,
loginReturnPath: "/create/final-review",
sessionUser: null,
onGuestPublished,
}),
);
await act(async () => {
await result.current.finalize();
});
expect(publishRule).toHaveBeenCalledWith({
title: "Published title",
summary: "Published summary",
document: {},
});
expect(onGuestPublished).toHaveBeenCalledWith({ skippedInvites: false });
expect(openLogin).not.toHaveBeenCalled();
expect(router.push).toHaveBeenCalledWith(
`/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`,
);
});
it("passes stakeholderEmails to publishRule on initial publish", async () => {
vi.mocked(publishRule).mockResolvedValue({
ok: true,
@@ -97,6 +194,7 @@ describe("useCreateFlowFinalize", () => {
openLogin,
updateState,
loginReturnPath: "/create/final-review",
sessionUser: signedIn,
}),
);
@@ -124,6 +222,7 @@ describe("useCreateFlowFinalize", () => {
openLogin,
updateState,
loginReturnPath: "/create/edit-rule",
sessionUser: signedIn,
}),
);
+64
View File
@@ -0,0 +1,64 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const isDatabaseConfiguredMock = vi.fn();
const findManyMock = vi.fn();
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
}));
vi.mock("../../lib/server/db", () => ({
prisma: {
publishedRule: {
findMany: (...args: unknown[]) => findManyMock(...args),
},
},
}));
import { GET } from "../../app/api/rules/route";
beforeEach(() => {
isDatabaseConfiguredMock.mockReset();
findManyMock.mockReset();
});
describe("GET /api/rules", () => {
it("returns 503 when the database is not configured", async () => {
isDatabaseConfiguredMock.mockReturnValue(false);
const res = await GET(
new NextRequest("https://x.test/api/rules"),
undefined,
);
expect(res.status).toBe(503);
expect(findManyMock).not.toHaveBeenCalled();
});
it("lists owned rules only", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
findManyMock.mockResolvedValueOnce([
{
id: "r1",
title: "Owned",
summary: null,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-02T00:00:00.000Z"),
},
]);
const res = await GET(
new NextRequest("https://x.test/api/rules?limit=10"),
undefined,
);
expect(res.status).toBe(200);
expect(findManyMock).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: { not: null } },
take: 10,
}),
);
const body = (await res.json()) as { rules: Array<{ id: string }> };
expect(body.rules).toEqual([{ id: "r1", title: "Owned", summary: null, createdAt: expect.any(String), updatedAt: expect.any(String) }]);
});
});
+86 -1
View File
@@ -6,6 +6,8 @@ const transactionMock = vi.fn();
const publishedRuleCreateMock = vi.fn();
const publishedRuleDeleteMock = vi.fn();
const sendInviteMock = vi.fn();
const rateLimitKeyMock = vi.fn(() => ({ ok: true as const }));
const issueGuestRuleClaimCookieMock = vi.fn();
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => true,
@@ -17,7 +19,7 @@ vi.mock("../../lib/server/session", () => ({
}));
vi.mock("../../lib/server/rateLimit", () => ({
rateLimitKey: () => ({ ok: true as const }),
rateLimitKey: (...args: unknown[]) => rateLimitKeyMock(...args),
}));
vi.mock("../../lib/server/mail", () => ({
@@ -28,6 +30,12 @@ vi.mock("../../lib/server/mail", () => ({
vi.mock("../../lib/server/hash", () => ({
newSessionToken: () => "x".repeat(32),
hashSessionToken: (t: string) => `hashed-${t}`,
hashRuleClaimToken: (t: string) => `claim-${t}`,
}));
vi.mock("../../lib/server/guestRuleClaim", () => ({
issueGuestRuleClaimCookie: (...args: unknown[]) =>
issueGuestRuleClaimCookieMock(...args),
}));
vi.mock("../../lib/server/db", () => ({
@@ -52,6 +60,9 @@ beforeEach(() => {
publishedRuleCreateMock.mockReset();
publishedRuleDeleteMock.mockReset();
sendInviteMock.mockReset();
rateLimitKeyMock.mockReset();
issueGuestRuleClaimCookieMock.mockReset();
rateLimitKeyMock.mockReturnValue({ ok: true as const });
getSessionUserMock.mockResolvedValue({
id: "user-1",
email: "owner@example.com",
@@ -82,6 +93,14 @@ describe("POST /api/rules", () => {
expect(res.status).toBe(200);
expect(transactionMock).not.toHaveBeenCalled();
expect(publishedRuleCreateMock).toHaveBeenCalledTimes(1);
expect(
(
publishedRuleCreateMock.mock.calls[0]?.[0] as {
data: { claimTokenHash?: unknown };
}
).data.claimTokenHash,
).toBeUndefined();
expect(issueGuestRuleClaimCookieMock).not.toHaveBeenCalled();
expect(sendInviteMock).not.toHaveBeenCalled();
});
@@ -171,4 +190,70 @@ describe("POST /api/rules", () => {
where: { id: "rule-new" },
});
});
it("creates an unlisted rule when there is no session", async () => {
getSessionUserMock.mockResolvedValueOnce(null);
publishedRuleCreateMock.mockResolvedValueOnce({
id: "rule-guest",
title: "Guest rule",
summary: null,
createdAt: new Date("2026-01-03T00:00:00.000Z"),
});
const res = await POST(
new NextRequest("https://x.test/api/rules", {
method: "POST",
headers: { "x-forwarded-for": "203.0.113.10" },
body: JSON.stringify({
title: "Guest rule",
summary: null,
document: {},
stakeholderEmails: ["invitee@example.com"],
}),
}),
undefined,
);
expect(res.status).toBe(200);
expect(rateLimitKeyMock).toHaveBeenCalledWith(
"publish-anonymous-ip:203.0.113.10",
60_000,
);
expect(publishedRuleCreateMock).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: null,
title: "Guest rule",
claimTokenHash: `claim-${"x".repeat(32)}`,
}),
});
expect(issueGuestRuleClaimCookieMock).toHaveBeenCalledWith("x".repeat(32));
expect(transactionMock).not.toHaveBeenCalled();
expect(sendInviteMock).not.toHaveBeenCalled();
const body = (await res.json()) as { rule: { id: string } };
expect(body.rule.id).toBe("rule-guest");
});
it("rate-limits anonymous publish by IP", async () => {
getSessionUserMock.mockResolvedValueOnce(null);
rateLimitKeyMock.mockReturnValueOnce({
ok: false as const,
retryAfterMs: 12_000,
});
const res = await POST(
new NextRequest("https://x.test/api/rules", {
method: "POST",
body: JSON.stringify({
title: "Guest rule",
summary: null,
document: {},
}),
}),
undefined,
);
expect(res.status).toBe(429);
expect(publishedRuleCreateMock).not.toHaveBeenCalled();
expect(issueGuestRuleClaimCookieMock).not.toHaveBeenCalled();
});
});
+8
View File
@@ -38,10 +38,15 @@ vi.mock("next/headers", () => ({
cookies: vi.fn(),
}));
vi.mock("../../lib/server/guestRuleClaim", () => ({
claimGuestRulesForUser: vi.fn().mockResolvedValue(0),
}));
import {
createSessionForUser,
pruneExpiredSessions,
} from "../../lib/server/session";
import { claimGuestRulesForUser } from "../../lib/server/guestRuleClaim";
beforeEach(() => {
sessionCreateMock.mockReset();
@@ -50,6 +55,8 @@ beforeEach(() => {
newSessionTokenMock.mockReset();
hashSessionTokenMock.mockReset();
loggerWarnMock.mockReset();
vi.mocked(claimGuestRulesForUser).mockReset();
vi.mocked(claimGuestRulesForUser).mockResolvedValue(0);
getSessionPepperMock.mockReturnValue("test-pepper");
newSessionTokenMock.mockReturnValue("token-raw");
@@ -109,6 +116,7 @@ describe("createSessionForUser cleanup behaviour", () => {
expect(result.token).toBe("token-raw");
expect(result.expiresAt).toBeInstanceOf(Date);
expect(sessionCreateMock).toHaveBeenCalledTimes(1);
expect(vi.mocked(claimGuestRulesForUser)).toHaveBeenCalledWith("user-1");
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(1);
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
where: { userId?: string; expiresAt: { lt: Date } };