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:
@@ -191,7 +191,7 @@ function CreateFlowLayoutContent({
|
|||||||
useState(false);
|
useState(false);
|
||||||
const [completedFlowBanner, setCompletedFlowBanner] = useState<{
|
const [completedFlowBanner, setCompletedFlowBanner] = useState<{
|
||||||
key: string;
|
key: string;
|
||||||
status: "positive" | "danger";
|
status: "positive" | "danger" | "warning";
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
@@ -257,6 +257,25 @@ function CreateFlowLayoutContent({
|
|||||||
openLogin,
|
openLogin,
|
||||||
updateState,
|
updateState,
|
||||||
loginReturnPath,
|
loginReturnPath,
|
||||||
|
sessionUser,
|
||||||
|
onGuestPublished: ({ skippedInvites }) => {
|
||||||
|
const completedCopy = create.reviewAndComplete.completed;
|
||||||
|
if (skippedInvites) {
|
||||||
|
setCompletedFlowBanner({
|
||||||
|
key: "guestInvitesSkipped",
|
||||||
|
status: "warning",
|
||||||
|
title: completedCopy.guestInvitesSkippedTitle,
|
||||||
|
description: completedCopy.guestInvitesSkippedDescription,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCompletedFlowBanner({
|
||||||
|
key: "guestClaimHint",
|
||||||
|
status: "warning",
|
||||||
|
title: completedCopy.guestClaimHintTitle,
|
||||||
|
description: completedCopy.guestClaimHintDescription,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -518,7 +537,7 @@ function CreateFlowLayoutContent({
|
|||||||
*/
|
*/
|
||||||
const topBanners: Array<{
|
const topBanners: Array<{
|
||||||
key: string;
|
key: string;
|
||||||
status: "danger" | "positive";
|
status: "danger" | "positive" | "warning";
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -655,7 +674,7 @@ function CreateFlowLayoutContent({
|
|||||||
<CreateFlowTopNav
|
<CreateFlowTopNav
|
||||||
hasShare={isCompletedStep}
|
hasShare={isCompletedStep}
|
||||||
hasExport={isCompletedStep}
|
hasExport={isCompletedStep}
|
||||||
hasEdit={isCompletedStep}
|
hasEdit={isCompletedStep && Boolean(sessionUser)}
|
||||||
hasManageStakeholders={isEditRuleStep}
|
hasManageStakeholders={isEditRuleStep}
|
||||||
saveDraftOnExit={saveDraftOnExit}
|
saveDraftOnExit={saveDraftOnExit}
|
||||||
onShare={
|
onShare={
|
||||||
@@ -882,7 +901,9 @@ function CreateFlowLayoutContent({
|
|||||||
buttonType="filled"
|
buttonType="filled"
|
||||||
palette="default"
|
palette="default"
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
disabled={isPublishing}
|
disabled={
|
||||||
|
isPublishing || (isFinalReviewLike && !sessionResolved)
|
||||||
|
}
|
||||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isFinalReviewLike) {
|
if (isFinalReviewLike) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
|
|
||||||
export type CompletedFlowActionBanner = {
|
export type CompletedFlowActionBanner = {
|
||||||
key: string;
|
key: string;
|
||||||
status: "positive" | "danger";
|
status: "positive" | "danger" | "warning";
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type OpenLogin = (args: {
|
|||||||
backdropVariant: "blurredYellow";
|
backdropVariant: "blurredYellow";
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
|
type SessionUser = { id: string; email: string };
|
||||||
|
|
||||||
export type UseCreateFlowFinalizeResult = {
|
export type UseCreateFlowFinalizeResult = {
|
||||||
publishBannerMessage: string | null;
|
publishBannerMessage: string | null;
|
||||||
setPublishBannerMessage: (_message: string | null) => void;
|
setPublishBannerMessage: (_message: string | null) => void;
|
||||||
@@ -34,6 +36,8 @@ export function useCreateFlowFinalize({
|
|||||||
openLogin,
|
openLogin,
|
||||||
updateState,
|
updateState,
|
||||||
loginReturnPath,
|
loginReturnPath,
|
||||||
|
sessionUser,
|
||||||
|
onGuestPublished,
|
||||||
}: {
|
}: {
|
||||||
state: CreateFlowState;
|
state: CreateFlowState;
|
||||||
router: AppRouterLike;
|
router: AppRouterLike;
|
||||||
@@ -41,6 +45,13 @@ export function useCreateFlowFinalize({
|
|||||||
updateState: (_patch: Partial<CreateFlowState>) => void;
|
updateState: (_patch: Partial<CreateFlowState>) => void;
|
||||||
/** Session gate return path (`?syncDraft=1`) — differs for `/create/edit-rule` vs `/create/final-review`. */
|
/** Session gate return path (`?syncDraft=1`) — differs for `/create/edit-rule` vs `/create/final-review`. */
|
||||||
loginReturnPath: string;
|
loginReturnPath: string;
|
||||||
|
/**
|
||||||
|
* `undefined` while `/api/auth/session` is in flight — finalize is a no-op
|
||||||
|
* until it resolves. `null` is a guest (anonymous publish).
|
||||||
|
*/
|
||||||
|
sessionUser: SessionUser | null | undefined;
|
||||||
|
/** Guest publish succeeded; `skippedInvites` when stakeholder emails were not sent. */
|
||||||
|
onGuestPublished?: (_info: { skippedInvites: boolean }) => void;
|
||||||
}): UseCreateFlowFinalizeResult {
|
}): UseCreateFlowFinalizeResult {
|
||||||
const [publishBannerMessage, setPublishBannerMessage] = useState<
|
const [publishBannerMessage, setPublishBannerMessage] = useState<
|
||||||
string | null
|
string | null
|
||||||
@@ -48,6 +59,8 @@ export function useCreateFlowFinalize({
|
|||||||
const [isPublishing, setIsPublishing] = useState(false);
|
const [isPublishing, setIsPublishing] = useState(false);
|
||||||
|
|
||||||
const finalize = useCallback(async () => {
|
const finalize = useCallback(async () => {
|
||||||
|
if (sessionUser === undefined) return;
|
||||||
|
|
||||||
setPublishBannerMessage(null);
|
setPublishBannerMessage(null);
|
||||||
const payloadResult = buildPublishPayload(state);
|
const payloadResult = buildPublishPayload(state);
|
||||||
if (payloadResult.ok === false) {
|
if (payloadResult.ok === false) {
|
||||||
@@ -100,9 +113,13 @@ export function useCreateFlowFinalize({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stakeholderEmails = (state.stakeholderEmails ?? []).filter(
|
const isGuest = sessionUser === null;
|
||||||
|
const rawStakeholderEmails = (state.stakeholderEmails ?? []).filter(
|
||||||
(e) => typeof e === "string" && e.trim() !== "",
|
(e) => typeof e === "string" && e.trim() !== "",
|
||||||
);
|
);
|
||||||
|
const stakeholderEmails = isGuest ? [] : rawStakeholderEmails;
|
||||||
|
const skippedGuestInvites = isGuest && rawStakeholderEmails.length > 0;
|
||||||
|
|
||||||
const publishResult = await publishRule({
|
const publishResult = await publishRule({
|
||||||
title,
|
title,
|
||||||
summary,
|
summary,
|
||||||
@@ -117,6 +134,9 @@ export function useCreateFlowFinalize({
|
|||||||
summary: summary ?? null,
|
summary: summary ?? null,
|
||||||
document: ruleDocument,
|
document: ruleDocument,
|
||||||
});
|
});
|
||||||
|
if (isGuest) {
|
||||||
|
onGuestPublished?.({ skippedInvites: skippedGuestInvites });
|
||||||
|
}
|
||||||
router.push(
|
router.push(
|
||||||
createFlowStepPath("completed", {
|
createFlowStepPath("completed", {
|
||||||
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
|
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
|
||||||
@@ -138,7 +158,15 @@ export function useCreateFlowFinalize({
|
|||||||
? publishResult.error
|
? publishResult.error
|
||||||
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
||||||
);
|
);
|
||||||
}, [state, router, openLogin, updateState, loginReturnPath]);
|
}, [
|
||||||
|
state,
|
||||||
|
router,
|
||||||
|
openLogin,
|
||||||
|
updateState,
|
||||||
|
loginReturnPath,
|
||||||
|
sessionUser,
|
||||||
|
onGuestPublished,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
publishBannerMessage,
|
publishBannerMessage,
|
||||||
|
|||||||
+71
-9
@@ -4,6 +4,7 @@ import { prisma } from "../../../lib/server/db";
|
|||||||
import { getSessionPepper, isDatabaseConfigured } from "../../../lib/server/env";
|
import { getSessionPepper, isDatabaseConfigured } from "../../../lib/server/env";
|
||||||
import {
|
import {
|
||||||
hashSessionToken,
|
hashSessionToken,
|
||||||
|
hashRuleClaimToken,
|
||||||
newSessionToken,
|
newSessionToken,
|
||||||
} from "../../../lib/server/hash";
|
} from "../../../lib/server/hash";
|
||||||
import { sendRuleStakeholderInviteEmail } from "../../../lib/server/mail";
|
import { sendRuleStakeholderInviteEmail } from "../../../lib/server/mail";
|
||||||
@@ -13,12 +14,12 @@ import {
|
|||||||
errorJson,
|
errorJson,
|
||||||
rateLimited,
|
rateLimited,
|
||||||
serverMisconfigured,
|
serverMisconfigured,
|
||||||
unauthorized,
|
|
||||||
} from "../../../lib/server/responses";
|
} from "../../../lib/server/responses";
|
||||||
import { logRouteError } from "../../../lib/server/requestId";
|
import { logRouteError } from "../../../lib/server/requestId";
|
||||||
import { stakeholderInviteVerifyUrl } from "../../../lib/server/ruleStakeholderInviteOps";
|
import { stakeholderInviteVerifyUrl } from "../../../lib/server/ruleStakeholderInviteOps";
|
||||||
import { STAKEHOLDER_INVITE_TTL_MS } from "../../../lib/server/ruleStakeholders";
|
import { STAKEHOLDER_INVITE_TTL_MS } from "../../../lib/server/ruleStakeholders";
|
||||||
import { getSessionUser } from "../../../lib/server/session";
|
import { getSessionUser } from "../../../lib/server/session";
|
||||||
|
import { issueGuestRuleClaimCookie } from "../../../lib/server/guestRuleClaim";
|
||||||
import { apiRoute } from "../../../lib/server/apiRoute";
|
import { apiRoute } from "../../../lib/server/apiRoute";
|
||||||
import { getPublicOrigin } from "../../../lib/server/publicOrigin";
|
import { getPublicOrigin } from "../../../lib/server/publicOrigin";
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +29,17 @@ import {
|
|||||||
import { readLimitedJson } from "../../../lib/server/validation/requestBody";
|
import { readLimitedJson } from "../../../lib/server/validation/requestBody";
|
||||||
import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
|
import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
|
||||||
|
|
||||||
|
/** One anonymous publish per IP per minute (in-memory limiter). */
|
||||||
|
const ANONYMOUS_PUBLISH_IP_MIN_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
function clientIp(request: NextRequest): string {
|
||||||
|
return (
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||||
|
request.headers.get("x-real-ip") ??
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const GET = apiRoute("rules.list", async (request: NextRequest) => {
|
export const GET = apiRoute("rules.list", async (request: NextRequest) => {
|
||||||
if (!isDatabaseConfigured()) {
|
if (!isDatabaseConfigured()) {
|
||||||
return dbUnavailable();
|
return dbUnavailable();
|
||||||
@@ -36,8 +48,12 @@ export const GET = apiRoute("rules.list", async (request: NextRequest) => {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
|
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
|
||||||
|
|
||||||
/** Public catalog: mirror profile “my rules” recency semantics (last touched first). */
|
/**
|
||||||
|
* Public catalog: owned rules only (last touched first). Guest / orphan rows
|
||||||
|
* (`userId` null) stay reachable at `/rules/[id]` and `GET /api/rules/[id]`.
|
||||||
|
*/
|
||||||
const rules = await prisma.publishedRule.findMany({
|
const rules = await prisma.publishedRule.findMany({
|
||||||
|
where: { userId: { not: null } },
|
||||||
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
||||||
take,
|
take,
|
||||||
select: {
|
select: {
|
||||||
@@ -60,9 +76,6 @@ export const POST = apiRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) {
|
|
||||||
return unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedBody = await readLimitedJson(request);
|
const parsedBody = await readLimitedJson(request);
|
||||||
if (parsedBody.ok === false) {
|
if (parsedBody.ok === false) {
|
||||||
@@ -75,16 +88,65 @@ export const POST = apiRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { title, summary, document, stakeholderEmails } = validated.data;
|
const { title, summary, document, stakeholderEmails } = validated.data;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const rl = rateLimitKey(
|
||||||
|
`publish-anonymous-ip:${ip}`,
|
||||||
|
ANONYMOUS_PUBLISH_IP_MIN_INTERVAL_MS,
|
||||||
|
);
|
||||||
|
if (rl.ok === false) {
|
||||||
|
return rateLimited(rl.retryAfterMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pepper: string;
|
||||||
|
try {
|
||||||
|
pepper = getSessionPepper();
|
||||||
|
} catch (err) {
|
||||||
|
logRouteError("rules.publish", requestId, err, {
|
||||||
|
phase: "getSessionPepper",
|
||||||
|
});
|
||||||
|
return serverMisconfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimToken = newSessionToken();
|
||||||
|
const claimTokenHash = hashRuleClaimToken(claimToken, pepper);
|
||||||
|
|
||||||
|
const rule = await prisma.publishedRule.create({
|
||||||
|
data: {
|
||||||
|
userId: null,
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
document: document as Prisma.InputJsonValue,
|
||||||
|
claimTokenHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await issueGuestRuleClaimCookie(claimToken);
|
||||||
|
} catch (err) {
|
||||||
|
logRouteError("rules.publish", requestId, err, {
|
||||||
|
phase: "issueGuestRuleClaimCookie",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
rule: {
|
||||||
|
id: rule.id,
|
||||||
|
title: rule.title,
|
||||||
|
summary: rule.summary,
|
||||||
|
createdAt: rule.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const inviteEmails = uniqueStakeholderEmailsForPublish(
|
const inviteEmails = uniqueStakeholderEmailsForPublish(
|
||||||
stakeholderEmails,
|
stakeholderEmails,
|
||||||
user.email,
|
user.email,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (inviteEmails.length > 0) {
|
if (inviteEmails.length > 0) {
|
||||||
const ip =
|
const ip = clientIp(request);
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
||||||
request.headers.get("x-real-ip") ??
|
|
||||||
"unknown";
|
|
||||||
const rl = rateLimitKey(`publish-stakeholders-ip:${ip}`, 60_000);
|
const rl = rateLimitKey(`publish-stakeholders-ip:${ip}`, 60_000);
|
||||||
if (rl.ok === false) {
|
if (rl.ok === false) {
|
||||||
return rateLimited(rl.retryAfterMs);
|
return rateLimited(rl.retryAfterMs);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function LoginView({
|
|||||||
className={`flex min-h-0 max-h-[90vh] w-full max-w-[560px] shrink-0 flex-col overflow-hidden rounded-[var(--radius-500,20px)] bg-[var(--color-surface-default-primary)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] z-[9999] ${className}`}
|
className={`flex min-h-0 max-h-[90vh] w-full max-w-[560px] shrink-0 flex-col overflow-hidden rounded-[var(--radius-500,20px)] bg-[var(--color-surface-default-primary)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] z-[9999] ${className}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
|
<ModalHeader onClose={onClose} showMoreOptionsButton={false} />
|
||||||
<div className="scrollbar-design flex min-h-0 flex-1 flex-col overflow-x-clip overflow-y-auto px-6 pb-8 pt-0">
|
<div className="scrollbar-design flex min-h-0 flex-1 flex-col overflow-x-clip overflow-y-auto px-6 pb-8 pt-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+6
-6
@@ -57,11 +57,11 @@ Wizard step → React screen rendering lives in [`createFlowScreenComponents.tsx
|
|||||||
|
|
||||||
### Stakeholder emails (`confirm-stakeholders`)
|
### Stakeholder emails (`confirm-stakeholders`)
|
||||||
|
|
||||||
The step persists **`stakeholderEmails`** on `CreateFlowState` (validated on `PUT /api/drafts/me`). On **first publish** (`POST /api/rules`), the server records [`RuleStakeholder`](../prisma/schema.prisma) rows and emails each address a one-time link to [`GET /api/invites/rule-stakeholder/verify`](../app/api/invites/rule-stakeholder/verify/route.ts). Opening the link creates or signs in the account for that email and redirects to the public rule; the rule also appears on the invitee’s **profile** with **view** access (not manage). **After publish**, owners manage invites from **`/create/edit-rule`** via **Manage Stakeholders**, which opens **`/create/confirm-stakeholders?reviewReturn=edit-rule&manageStakeholders=1`** (same screen layout as the pre-publish step, backed by [`GET` / `POST` `DELETE` / resend](../app/api/rules/[id]/stakeholders/route.ts)). **`PATCH /api/rules/[id]`** still does not read stakeholder emails from the wizard draft.
|
The step persists **`stakeholderEmails`** on `CreateFlowState` (validated on `PUT /api/drafts/me`). On **first publish** (`POST /api/rules`) **with a session**, the server records [`RuleStakeholder`](../prisma/schema.prisma) rows and emails each address a one-time link to [`GET /api/invites/rule-stakeholder/verify`](../app/api/invites/rule-stakeholder/verify/route.ts). Guests can still publish; invites are skipped until they sign in. Opening the link creates or signs in the account for that email and redirects to the public rule; the rule also appears on the invitee’s **profile** with **view** access (not manage). **After publish**, owners manage invites from **`/create/edit-rule`** via **Manage Stakeholders**, which opens **`/create/confirm-stakeholders?reviewReturn=edit-rule&manageStakeholders=1`** (same screen layout as the pre-publish step, backed by [`GET` / `POST` `DELETE` / resend](../app/api/rules/[id]/stakeholders/route.ts)). **`PATCH /api/rules/[id]`** still does not read stakeholder emails from the wizard draft.
|
||||||
|
|
||||||
### Fresh start vs continue draft (signed-in + sync)
|
### Fresh start vs continue draft (signed-in + sync)
|
||||||
|
|
||||||
**Established pattern:** anonymous and signed-in users should see the **same** wizard when starting a **new** rule from marketing or profile: empty state at the first step, with no surprise reload of old work. Signed-in users additionally get **Save & Exit** and **publish**; their in-progress payload may also live on **`/api/drafts/me`** when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`.
|
**Established pattern:** anonymous and signed-in users should see the **same** wizard when starting a **new** rule from marketing or profile: empty state at the first step, with no surprise reload of old work. Both can **publish** (guests get an unlisted public `/rules/{id}` with no owner). Signed-in users additionally get **Save & Exit**; their in-progress payload may also live on **`/api/drafts/me`** when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`.
|
||||||
|
|
||||||
- **New rule entry** (always a clean slate): call [`prepareFreshCreateFlowEntry`](../app/(app)/create/utils/prepareFreshCreateFlowEntry.ts) **before** `router.push` into `/create` or `/create/review-template/...`. It clears **`create-flow-anonymous`** and the core-value-details `localStorage` key; when sync is on, it **`DELETE`s `/api/drafts/me`** so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) does not rehydrate a stale server draft after local storage was wiped.
|
- **New rule entry** (always a clean slate): call [`prepareFreshCreateFlowEntry`](../app/(app)/create/utils/prepareFreshCreateFlowEntry.ts) **before** `router.push` into `/create` or `/create/review-template/...`. It clears **`create-flow-anonymous`** and the core-value-details `localStorage` key; when sync is on, it **`DELETE`s `/api/drafts/me`** so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) does not rehydrate a stale server draft after local storage was wiped.
|
||||||
- **Continue saved draft** (profile): do **not** call `prepareFreshCreateFlowEntry`. Clear the same `localStorage` keys **only** (see [`ProfilePageClient`](../app/(app)/profile/ProfilePageClient.tsx) `handleContinueDraft`) so the client mirror is empty, then navigate to **`/create/{savedStep}`**. Hydration loads the server draft; the URL may be corrected to `currentStep` when it differs from the path.
|
- **Continue saved draft** (profile): do **not** call `prepareFreshCreateFlowEntry`. Clear the same `localStorage` keys **only** (see [`ProfilePageClient`](../app/(app)/profile/ProfilePageClient.tsx) `handleContinueDraft`) so the client mirror is empty, then navigate to **`/create/{savedStep}`**. Hydration loads the server draft; the URL may be corrected to `currentStep` when it differs from the path.
|
||||||
@@ -79,7 +79,7 @@ Call sites for **`prepareFreshCreateFlowEntry`**: [`Top.container.tsx`](../app/c
|
|||||||
|
|
||||||
From that page, **Customize** pre-fills the custom-rule selections on the current `CreateFlowState` (via [`buildTemplateCustomizePrefill`](../lib/create/applyTemplatePrefill.ts)) and routes to **`/create/core-values`** when the community name (`state.title`) is already set, otherwise to **`/create/informational`**. Name-only is the gate because other community-stage fields (e.g. `communityStructureChipSnapshots`) are sticky once the user lands on those screens; a non-empty title is also the minimum bar [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) enforces, so the two checks stay aligned. No query-param plumbing: state persists via the usual anonymous/server-draft mirrors.
|
From that page, **Customize** pre-fills the custom-rule selections on the current `CreateFlowState` (via [`buildTemplateCustomizePrefill`](../lib/create/applyTemplatePrefill.ts)) and routes to **`/create/core-values`** when the community name (`state.title`) is already set, otherwise to **`/create/informational`**. Name-only is the gate because other community-stage fields (e.g. `communityStructureChipSnapshots`) are sticky once the user lands on those screens; a non-empty title is also the minimum bar [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) enforces, so the two checks stay aligned. No query-param plumbing: state persists via the usual anonymous/server-draft mirrors.
|
||||||
|
|
||||||
**Use without changes** writes the template's `body.sections` into `state.sections` (chip titles only; bodies are empty in seeded templates), resets any prior Customize chip selections so they don't bleed into `document.coreValues`, and routes to **`/create/confirm-stakeholders`**. It does **not** copy the template catalog `description` into `state.summary` — the published rule summary comes from **`communityContext` first**, then `summary`, when the user publishes. At publish, [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) derives `methodSelections` from those section titles, merges preset copy into `document.sections`, and emits structured `methodSelections`. The user then exits via the normal **`final-review → handleFinalize → publishRule`** pipeline, which gates unauthenticated publishes with a **401 → `openLogin`** redirect back to `/create/final-review?syncDraft=1`.
|
**Use without changes** writes the template's `body.sections` into `state.sections` (chip titles only; bodies are empty in seeded templates), resets any prior Customize chip selections so they don't bleed into `document.coreValues`, and routes to **`/create/confirm-stakeholders`**. It does **not** copy the template catalog `description` into `state.summary` — the published rule summary comes from **`communityContext` first**, then `summary`, when the user publishes. At publish, [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) derives `methodSelections` from those section titles, merges preset copy into `document.sections`, and emits structured `methodSelections`. The user then exits via the normal **`final-review → handleFinalize → publishRule`** pipeline. Guests can publish without signing in (`POST /api/rules` with `userId` null); stakeholder invites are sent only when a session exists.
|
||||||
|
|
||||||
**Entering a template before community stage is done.** When `state.title` is empty, both handlers apply their side effects eagerly (prefill for Customize; `sections` for Use without changes) *and* pin a `pendingTemplateAction: { slug, mode }` on `CreateFlowState` before routing to `/create/informational`. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s past itself — to `/create/core-values` for `customize`, `/create/confirm-stakeholders` for `useWithoutChanges`. The user never sees the community-review page in that flow because their intent was already expressed at the template-review step. `replace` (not `push`) keeps `community-save` as the Back-button target from the destination. The action is cleared on the first fire so later direct visits to `/create/review` render normally.
|
**Entering a template before community stage is done.** When `state.title` is empty, both handlers apply their side effects eagerly (prefill for Customize; `sections` for Use without changes) *and* pin a `pendingTemplateAction: { slug, mode }` on `CreateFlowState` before routing to `/create/informational`. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s past itself — to `/create/core-values` for `customize`, `/create/confirm-stakeholders` for `useWithoutChanges`. The user never sees the community-review page in that flow because their intent was already expressed at the template-review step. `replace` (not `push`) keeps `community-save` as the Back-button target from the destination. The action is cleared on the first fire so later direct visits to `/create/review` render normally.
|
||||||
|
|
||||||
@@ -103,10 +103,10 @@ Only one `?fromFlow=1` marker exists, on one hop (`/create/review` → `/templat
|
|||||||
|
|
||||||
## Persistence and exit
|
## Persistence and exit
|
||||||
|
|
||||||
| Mode | Where progress lives | Save & Exit / server draft |
|
| Mode | Where progress lives | Save & Exit / publish |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **Anonymous** | `localStorage` key **`create-flow-anonymous`** | **Exit** opens save-progress magic link; after verify, optional **PUT** `/api/drafts/me` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` (see Tickets 4–5 in [guides/backend-linear-tickets.md](guides/backend-linear-tickets.md)). |
|
| **Anonymous** | `localStorage` key **`create-flow-anonymous`** | **Exit** opens save-progress magic link; after verify, optional **PUT** `/api/drafts/me` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` (see Tickets 4–5 in [guides/backend-linear-tickets.md](guides/backend-linear-tickets.md)). **Finalize** `POST`s `/api/rules` without a session (`userId` null) and sets httpOnly **`cr_rule_claim`**. The public URL works; the row is omitted from `GET /api/rules` until claimed. Signing in on the same browser attaches `userId` (profile, edit, invites). |
|
||||||
| **Signed-in** | In-memory React state in **`CreateFlowContext`** | **Save & Exit** from the **`community-structure`** step onward (step index ≥ `community-structure`) may **PUT** `/api/drafts/me` when sync is on. **Sign out** is on profile, not in the create top nav. |
|
| **Signed-in** | In-memory React state in **`CreateFlowContext`** | **Save & Exit** from the **`community-structure`** step onward (step index ≥ `community-structure`) may **PUT** `/api/drafts/me` when sync is on. **Finalize** stores the rule with **`userId`**. **Sign out** is on profile, not in the create top nav. |
|
||||||
|
|
||||||
Details and edge cases (conflict confirm, banners, `?syncDraft=1`) match **Ticket 4**, **Ticket 5**, and [`docs/guides/backend-roadmap.md`](guides/backend-roadmap.md) §12.
|
Details and edge cases (conflict confirm, banners, `?syncDraft=1`) match **Ticket 4**, **Ticket 5**, and [`docs/guides/backend-roadmap.md`](guides/backend-roadmap.md) §12.
|
||||||
|
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ Align JSON shapes with `app/(app)/create/types.ts` as it matures.
|
|||||||
Match the current API behavior; tighten as product evolves:
|
Match the current API behavior; tighten as product evolves:
|
||||||
|
|
||||||
- **`GET /api/drafts/me` / `PUT /api/drafts/me`:** Authenticated user only; draft is **scoped to that user** (`userId`).
|
- **`GET /api/drafts/me` / `PUT /api/drafts/me`:** Authenticated user only; draft is **scoped to that user** (`userId`).
|
||||||
- **`POST /api/rules`:** Authenticated user only; rule is stored with **`userId`** (owner).
|
- **`POST /api/rules`:** Session optional. Signed-in publishes store **`userId`** (owner) and may send stakeholder invites. Guests create an unlisted row (`userId` null) plus hashed **`claimTokenHash`** and httpOnly **`cr_rule_claim`**, rate-limited per IP; `/rules/[id]` still works. Invites are ignored without a session. Sign-in (`createSessionForUser`) claims matching ownerless rows.
|
||||||
- **`GET /api/rules`:** **Public list** of published rules (metadata: id, title, summary, timestamps)—no auth required today. **Authenticated “my rules”** uses **`GET /api/rules/me`** (see §1 profile / account table).
|
- **`GET /api/rules`:** **Public list** of **owned** published rules (metadata: id, title, summary, timestamps)—no auth required today. Ownerless rows are omitted. **Authenticated “my rules”** uses **`GET /api/rules/me`** (see §1 profile / account table).
|
||||||
- **Profile / owner scope (planned):** Authenticated **list own rules**, **delete own rule**, **duplicate own rule**—required for the signed-in dashboard in design; **v1 shipped handlers** may not include these until that work lands.
|
- **Profile / owner scope (planned):** Authenticated **list own rules**, **delete own rule**, **duplicate own rule**—required for the signed-in dashboard in design; **v1 shipped handlers** may not include these until that work lands.
|
||||||
- **Delete account (planned):** Authenticated endpoint + UX to remove the user record per policy (cascade vs orphan `PublishedRule`, drafts, sessions)—Ticket 15. **Change email** is **not** part of that milestone; implement via **[CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)** (Ticket 20 — verified email updates).
|
- **Delete account (planned):** Authenticated endpoint + UX to remove the user record per policy (cascade vs orphan `PublishedRule`, drafts, sessions)—Ticket 15. **Change email** is **not** part of that milestone; implement via **[CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)** (Ticket 20 — verified email updates).
|
||||||
- **v1 (shipped today):** No **editing** or **deleting** published rules via API in current handlers; no **sharing** or **collaborative ownership**—treat each rule as **owned by one user** until product defines more.
|
- **v1 (shipped today):** No **editing** or **deleting** published rules via API in current handlers; no **sharing** or **collaborative ownership**—treat each rule as **owned by one user** until product defines more.
|
||||||
|
|||||||
@@ -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}`);
|
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 {
|
export function newSessionToken(): string {
|
||||||
return randomBytes(32).toString("base64url");
|
return randomBytes(32).toString("base64url");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { User } from "@prisma/client";
|
|||||||
import { logger } from "../logger";
|
import { logger } from "../logger";
|
||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
import { getSessionPepper } from "./env";
|
import { getSessionPepper } from "./env";
|
||||||
|
import { claimGuestRulesForUser } from "./guestRuleClaim";
|
||||||
import { hashSessionToken, newSessionToken } from "./hash";
|
import { hashSessionToken, newSessionToken } from "./hash";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,6 +137,12 @@ export async function createSessionForUser(
|
|||||||
logger.warn("[session] expired-row cleanup failed", err);
|
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 };
|
return { token, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,5 +14,9 @@
|
|||||||
"exportFailedTitle": "Could not export",
|
"exportFailedTitle": "Could not export",
|
||||||
"exportFailedDescription": "Something went wrong. Refresh the page and try again.",
|
"exportFailedDescription": "Something went wrong. Refresh the page and try again.",
|
||||||
"exportEmptyDocumentTitle": "Could not export",
|
"exportEmptyDocumentTitle": "Could not export",
|
||||||
"exportEmptyDocumentDescription": "Your rule document is not available to export yet."
|
"exportEmptyDocumentDescription": "Your rule document is not available to export yet.",
|
||||||
|
"guestInvitesSkippedTitle": "Stakeholder invites were not sent",
|
||||||
|
"guestInvitesSkippedDescription": "Sign in to invite people by email. Until then, share the public link to this CommunityRule.",
|
||||||
|
"guestClaimHintTitle": "Sign in to keep this rule on your profile",
|
||||||
|
"guestClaimHintDescription": "Use Log in on this same browser to attach the rule to your account so you can edit it later."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,20 @@
|
|||||||
"_comment": "Cookies Settings. Figma Content page Template (19003:23305): banner + labeled body stacks. There are no optional cookies to toggle.",
|
"_comment": "Cookies Settings. Figma Content page Template (19003:23305): banner + labeled body stacks. There are no optional cookies to toggle.",
|
||||||
"banner": {
|
"banner": {
|
||||||
"title": "Cookies Settings",
|
"title": "Cookies Settings",
|
||||||
"description": "CommunityRule uses one cookie, to keep you signed in.",
|
"description": "CommunityRule uses cookies to keep you signed in and to let you claim a rule you published without an account.",
|
||||||
"author": "Media Economies Design Lab",
|
"author": "Media Economies Design Lab",
|
||||||
"date": "2026-08-15"
|
"date": "2026-08-15"
|
||||||
},
|
},
|
||||||
"updated": "Last updated August 15, 2026.",
|
"updated": "Last updated August 15, 2026.",
|
||||||
"intro": [
|
"intro": [
|
||||||
"CommunityRule sets one cookie, named cr_session, after you sign in with a magic link. It keeps you signed in for up to 30 days. There are no advertising or analytics cookies to turn off."
|
"CommunityRule sets a cookie named cr_session after you sign in with a magic link. It keeps you signed in for up to 30 days. If you publish a CommunityRule without signing in, we also set cr_rule_claim so that signing in later on this browser can attach that rule to your account. There are no advertising or analytics cookies to turn off."
|
||||||
],
|
],
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
"heading": "The session cookie",
|
"heading": "The session cookie",
|
||||||
"paragraphs": [
|
"paragraphs": [
|
||||||
"Sign out to clear it on this browser. Blocking it in your browser will sign you out and keep you from staying signed in. Browsing templates does not set this cookie."
|
"Sign out to clear the session cookie on this browser. Blocking it will sign you out and keep you from staying signed in. Browsing templates does not set this cookie.",
|
||||||
|
"The claim cookie is set only when you publish without an account. It is httpOnly, lasts up to 30 days, and is cleared after a successful sign-in claim. Clearing this site’s cookies means you cannot attach that guest-published rule to an account from this browser."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
{
|
{
|
||||||
"heading": "What we collect",
|
"heading": "What we collect",
|
||||||
"paragraphs": [
|
"paragraphs": [
|
||||||
"Your email, to sign you in, change your email, or invite a stakeholder. In-progress drafts in your browser and, after you save, on our servers. Files you attach to a Rule. Messages you send through the organizer form, which go to MEDLab by email. Page performance metrics (URL, timing, and browser), which are written to our server logs and are not tied to your account.",
|
"Your email, to sign you in, change your email, or invite a stakeholder. In-progress drafts in your browser and, after you save, on our servers. Files you attach to a Rule. Messages you send through the organizer form, which go to MEDLab by email. Page performance metrics (URL, timing, and browser), which are written to our server logs and are not tied to your account. If you publish without signing in, a claim cookie on this browser so a later sign-in can attach that Rule to your account.",
|
||||||
"We do not sell this information or use it for advertising. As a public university project, some records may be subject to the Colorado Open Records Act."
|
"We do not sell this information or use it for advertising. As a public university project, some records may be subject to the Colorado Open Records Act."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PublishedRule" ADD COLUMN "claimTokenHash" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PublishedRule_claimTokenHash_key" ON "PublishedRule"("claimTokenHash");
|
||||||
@@ -79,6 +79,9 @@ model PublishedRule {
|
|||||||
document Json
|
document Json
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
/// Hashed one-time secret so the publishing browser can claim this row on sign-in.
|
||||||
|
/// Null for owned publishes and after a successful claim.
|
||||||
|
claimTokenHash String? @unique
|
||||||
|
|
||||||
stakeholders RuleStakeholder[]
|
stakeholders RuleStakeholder[]
|
||||||
|
|
||||||
|
|||||||
@@ -139,4 +139,17 @@ describe("Login", () => {
|
|||||||
screen.getByRole("link", { name: /back to home/i }),
|
screen.getByRole("link", { name: /back to home/i }),
|
||||||
).toBeInTheDocument();
|
).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ describe("legal document pages", () => {
|
|||||||
screen.getByText(/There are no advertising or analytics cookies to turn off/),
|
screen.getByText(/There are no advertising or analytics cookies to turn off/),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText(/cr_session/)).toBeInTheDocument();
|
expect(screen.getByText(/cr_session/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/cr_rule_claim/)).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole("link", { name: "Privacy Policy" })[0],
|
screen.getAllByRole("link", { name: "Privacy Policy" })[0],
|
||||||
).toHaveAttribute("href", "/privacy");
|
).toHaveAttribute("href", "/privacy");
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 emptyState = {} as CreateFlowState;
|
||||||
|
const signedIn = { id: "user-1", email: "owner@example.com" };
|
||||||
|
|
||||||
describe("useCreateFlowFinalize", () => {
|
describe("useCreateFlowFinalize", () => {
|
||||||
const router = { push: vi.fn() };
|
const router = { push: vi.fn() };
|
||||||
const updateState = vi.fn();
|
const updateState = vi.fn();
|
||||||
const openLogin = vi.fn();
|
const openLogin = vi.fn();
|
||||||
|
const onGuestPublished = vi.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(publishRule).mockReset();
|
vi.mocked(publishRule).mockReset();
|
||||||
@@ -41,6 +43,7 @@ describe("useCreateFlowFinalize", () => {
|
|||||||
router.push.mockReset();
|
router.push.mockReset();
|
||||||
updateState.mockReset();
|
updateState.mockReset();
|
||||||
openLogin.mockReset();
|
openLogin.mockReset();
|
||||||
|
onGuestPublished.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -61,6 +64,7 @@ describe("useCreateFlowFinalize", () => {
|
|||||||
openLogin,
|
openLogin,
|
||||||
updateState,
|
updateState,
|
||||||
loginReturnPath: "/create/final-review",
|
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 () => {
|
it("passes stakeholderEmails to publishRule on initial publish", async () => {
|
||||||
vi.mocked(publishRule).mockResolvedValue({
|
vi.mocked(publishRule).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -97,6 +194,7 @@ describe("useCreateFlowFinalize", () => {
|
|||||||
openLogin,
|
openLogin,
|
||||||
updateState,
|
updateState,
|
||||||
loginReturnPath: "/create/final-review",
|
loginReturnPath: "/create/final-review",
|
||||||
|
sessionUser: signedIn,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -124,6 +222,7 @@ describe("useCreateFlowFinalize", () => {
|
|||||||
openLogin,
|
openLogin,
|
||||||
updateState,
|
updateState,
|
||||||
loginReturnPath: "/create/edit-rule",
|
loginReturnPath: "/create/edit-rule",
|
||||||
|
sessionUser: signedIn,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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) }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,8 @@ const transactionMock = vi.fn();
|
|||||||
const publishedRuleCreateMock = vi.fn();
|
const publishedRuleCreateMock = vi.fn();
|
||||||
const publishedRuleDeleteMock = vi.fn();
|
const publishedRuleDeleteMock = vi.fn();
|
||||||
const sendInviteMock = vi.fn();
|
const sendInviteMock = vi.fn();
|
||||||
|
const rateLimitKeyMock = vi.fn(() => ({ ok: true as const }));
|
||||||
|
const issueGuestRuleClaimCookieMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../lib/server/env", () => ({
|
vi.mock("../../lib/server/env", () => ({
|
||||||
isDatabaseConfigured: () => true,
|
isDatabaseConfigured: () => true,
|
||||||
@@ -17,7 +19,7 @@ vi.mock("../../lib/server/session", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../lib/server/rateLimit", () => ({
|
vi.mock("../../lib/server/rateLimit", () => ({
|
||||||
rateLimitKey: () => ({ ok: true as const }),
|
rateLimitKey: (...args: unknown[]) => rateLimitKeyMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../lib/server/mail", () => ({
|
vi.mock("../../lib/server/mail", () => ({
|
||||||
@@ -28,6 +30,12 @@ vi.mock("../../lib/server/mail", () => ({
|
|||||||
vi.mock("../../lib/server/hash", () => ({
|
vi.mock("../../lib/server/hash", () => ({
|
||||||
newSessionToken: () => "x".repeat(32),
|
newSessionToken: () => "x".repeat(32),
|
||||||
hashSessionToken: (t: string) => `hashed-${t}`,
|
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", () => ({
|
vi.mock("../../lib/server/db", () => ({
|
||||||
@@ -52,6 +60,9 @@ beforeEach(() => {
|
|||||||
publishedRuleCreateMock.mockReset();
|
publishedRuleCreateMock.mockReset();
|
||||||
publishedRuleDeleteMock.mockReset();
|
publishedRuleDeleteMock.mockReset();
|
||||||
sendInviteMock.mockReset();
|
sendInviteMock.mockReset();
|
||||||
|
rateLimitKeyMock.mockReset();
|
||||||
|
issueGuestRuleClaimCookieMock.mockReset();
|
||||||
|
rateLimitKeyMock.mockReturnValue({ ok: true as const });
|
||||||
getSessionUserMock.mockResolvedValue({
|
getSessionUserMock.mockResolvedValue({
|
||||||
id: "user-1",
|
id: "user-1",
|
||||||
email: "owner@example.com",
|
email: "owner@example.com",
|
||||||
@@ -82,6 +93,14 @@ describe("POST /api/rules", () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(transactionMock).not.toHaveBeenCalled();
|
expect(transactionMock).not.toHaveBeenCalled();
|
||||||
expect(publishedRuleCreateMock).toHaveBeenCalledTimes(1);
|
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();
|
expect(sendInviteMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,4 +190,70 @@ describe("POST /api/rules", () => {
|
|||||||
where: { id: "rule-new" },
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,10 +38,15 @@ vi.mock("next/headers", () => ({
|
|||||||
cookies: vi.fn(),
|
cookies: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/guestRuleClaim", () => ({
|
||||||
|
claimGuestRulesForUser: vi.fn().mockResolvedValue(0),
|
||||||
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createSessionForUser,
|
createSessionForUser,
|
||||||
pruneExpiredSessions,
|
pruneExpiredSessions,
|
||||||
} from "../../lib/server/session";
|
} from "../../lib/server/session";
|
||||||
|
import { claimGuestRulesForUser } from "../../lib/server/guestRuleClaim";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionCreateMock.mockReset();
|
sessionCreateMock.mockReset();
|
||||||
@@ -50,6 +55,8 @@ beforeEach(() => {
|
|||||||
newSessionTokenMock.mockReset();
|
newSessionTokenMock.mockReset();
|
||||||
hashSessionTokenMock.mockReset();
|
hashSessionTokenMock.mockReset();
|
||||||
loggerWarnMock.mockReset();
|
loggerWarnMock.mockReset();
|
||||||
|
vi.mocked(claimGuestRulesForUser).mockReset();
|
||||||
|
vi.mocked(claimGuestRulesForUser).mockResolvedValue(0);
|
||||||
|
|
||||||
getSessionPepperMock.mockReturnValue("test-pepper");
|
getSessionPepperMock.mockReturnValue("test-pepper");
|
||||||
newSessionTokenMock.mockReturnValue("token-raw");
|
newSessionTokenMock.mockReturnValue("token-raw");
|
||||||
@@ -109,6 +116,7 @@ describe("createSessionForUser cleanup behaviour", () => {
|
|||||||
expect(result.token).toBe("token-raw");
|
expect(result.token).toBe("token-raw");
|
||||||
expect(result.expiresAt).toBeInstanceOf(Date);
|
expect(result.expiresAt).toBeInstanceOf(Date);
|
||||||
expect(sessionCreateMock).toHaveBeenCalledTimes(1);
|
expect(sessionCreateMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(vi.mocked(claimGuestRulesForUser)).toHaveBeenCalledWith("user-1");
|
||||||
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(1);
|
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(1);
|
||||||
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
|
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
|
||||||
where: { userId?: string; expiresAt: { lt: Date } };
|
where: { userId?: string; expiresAt: { lt: Date } };
|
||||||
|
|||||||
Reference in New Issue
Block a user