From 950b3224ac7fff8bc8920cd9e6d922560df4c180 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:01:10 -0600 Subject: [PATCH] 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 --- app/(app)/create/CreateFlowLayoutClient.tsx | 29 +++- .../hooks/useCompletedRuleShareExport.ts | 2 +- .../create/hooks/useCreateFlowFinalize.ts | 32 ++++- app/api/rules/route.ts | 80 +++++++++-- app/components/modals/Login/Login.view.tsx | 2 +- docs/create-flow.md | 12 +- docs/guides/backend-roadmap.md | 4 +- lib/server/guestRuleClaim.ts | 109 +++++++++++++++ lib/server/hash.ts | 5 + lib/server/session.ts | 7 + .../create/reviewAndComplete/completed.json | 6 +- messages/en/pages/cookies.json | 7 +- messages/en/pages/privacy.json | 2 +- .../migration.sql | 5 + prisma/schema.prisma | 3 + tests/components/Login.test.tsx | 13 ++ tests/pages/legal.test.tsx | 1 + tests/unit/guestRuleClaim.test.ts | 126 ++++++++++++++++++ tests/unit/hashRuleClaimToken.test.ts | 17 +++ .../unit/hooks/useCreateFlowFinalize.test.tsx | 99 ++++++++++++++ tests/unit/rulesListRoute.test.ts | 64 +++++++++ tests/unit/rulesPublishPostRoute.test.ts | 87 +++++++++++- tests/unit/sessionLifecycle.test.ts | 8 ++ 23 files changed, 689 insertions(+), 31 deletions(-) create mode 100644 lib/server/guestRuleClaim.ts create mode 100644 prisma/migrations/20260826160000_add_published_rule_claim_token/migration.sql create mode 100644 tests/unit/guestRuleClaim.test.ts create mode 100644 tests/unit/hashRuleClaimToken.test.ts create mode 100644 tests/unit/rulesListRoute.test.ts diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 7be3c32..ae48fcf 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -191,7 +191,7 @@ function CreateFlowLayoutContent({ useState(false); const [completedFlowBanner, setCompletedFlowBanner] = useState<{ key: string; - status: "positive" | "danger"; + status: "positive" | "danger" | "warning"; title: string; description?: string; } | null>(null); @@ -257,6 +257,25 @@ function CreateFlowLayoutContent({ openLogin, updateState, 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 { @@ -518,7 +537,7 @@ function CreateFlowLayoutContent({ */ const topBanners: Array<{ key: string; - status: "danger" | "positive"; + status: "danger" | "positive" | "warning"; title: string; description?: string; onClose: () => void; @@ -655,7 +674,7 @@ function CreateFlowLayoutContent({ { if (isFinalReviewLike) { diff --git a/app/(app)/create/hooks/useCompletedRuleShareExport.ts b/app/(app)/create/hooks/useCompletedRuleShareExport.ts index b3120b0..5ccf4fa 100644 --- a/app/(app)/create/hooks/useCompletedRuleShareExport.ts +++ b/app/(app)/create/hooks/useCompletedRuleShareExport.ts @@ -24,7 +24,7 @@ import { export type CompletedFlowActionBanner = { key: string; - status: "positive" | "danger"; + status: "positive" | "danger" | "warning"; title: string; description?: string; }; diff --git a/app/(app)/create/hooks/useCreateFlowFinalize.ts b/app/(app)/create/hooks/useCreateFlowFinalize.ts index 7329c9d..3031993 100644 --- a/app/(app)/create/hooks/useCreateFlowFinalize.ts +++ b/app/(app)/create/hooks/useCreateFlowFinalize.ts @@ -20,6 +20,8 @@ type OpenLogin = (args: { backdropVariant: "blurredYellow"; }) => void; +type SessionUser = { id: string; email: string }; + export type UseCreateFlowFinalizeResult = { publishBannerMessage: string | null; setPublishBannerMessage: (_message: string | null) => void; @@ -34,6 +36,8 @@ export function useCreateFlowFinalize({ openLogin, updateState, loginReturnPath, + sessionUser, + onGuestPublished, }: { state: CreateFlowState; router: AppRouterLike; @@ -41,6 +45,13 @@ export function useCreateFlowFinalize({ updateState: (_patch: Partial) => void; /** Session gate return path (`?syncDraft=1`) — differs for `/create/edit-rule` vs `/create/final-review`. */ 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 { const [publishBannerMessage, setPublishBannerMessage] = useState< string | null @@ -48,6 +59,8 @@ export function useCreateFlowFinalize({ const [isPublishing, setIsPublishing] = useState(false); const finalize = useCallback(async () => { + if (sessionUser === undefined) return; + setPublishBannerMessage(null); const payloadResult = buildPublishPayload(state); if (payloadResult.ok === false) { @@ -100,9 +113,13 @@ export function useCreateFlowFinalize({ return; } - const stakeholderEmails = (state.stakeholderEmails ?? []).filter( + const isGuest = sessionUser === null; + const rawStakeholderEmails = (state.stakeholderEmails ?? []).filter( (e) => typeof e === "string" && e.trim() !== "", ); + const stakeholderEmails = isGuest ? [] : rawStakeholderEmails; + const skippedGuestInvites = isGuest && rawStakeholderEmails.length > 0; + const publishResult = await publishRule({ title, summary, @@ -117,6 +134,9 @@ export function useCreateFlowFinalize({ summary: summary ?? null, document: ruleDocument, }); + if (isGuest) { + onGuestPublished?.({ skippedInvites: skippedGuestInvites }); + } router.push( createFlowStepPath("completed", { [CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]: @@ -138,7 +158,15 @@ export function useCreateFlowFinalize({ ? publishResult.error : messages.create.reviewAndComplete.publish.genericPublishFailed, ); - }, [state, router, openLogin, updateState, loginReturnPath]); + }, [ + state, + router, + openLogin, + updateState, + loginReturnPath, + sessionUser, + onGuestPublished, + ]); return { publishBannerMessage, diff --git a/app/api/rules/route.ts b/app/api/rules/route.ts index 839be10..c0b529d 100644 --- a/app/api/rules/route.ts +++ b/app/api/rules/route.ts @@ -4,6 +4,7 @@ import { prisma } from "../../../lib/server/db"; import { getSessionPepper, isDatabaseConfigured } from "../../../lib/server/env"; import { hashSessionToken, + hashRuleClaimToken, newSessionToken, } from "../../../lib/server/hash"; import { sendRuleStakeholderInviteEmail } from "../../../lib/server/mail"; @@ -13,12 +14,12 @@ import { errorJson, rateLimited, serverMisconfigured, - unauthorized, } from "../../../lib/server/responses"; import { logRouteError } from "../../../lib/server/requestId"; import { stakeholderInviteVerifyUrl } from "../../../lib/server/ruleStakeholderInviteOps"; import { STAKEHOLDER_INVITE_TTL_MS } from "../../../lib/server/ruleStakeholders"; import { getSessionUser } from "../../../lib/server/session"; +import { issueGuestRuleClaimCookie } from "../../../lib/server/guestRuleClaim"; import { apiRoute } from "../../../lib/server/apiRoute"; import { getPublicOrigin } from "../../../lib/server/publicOrigin"; import { @@ -28,6 +29,17 @@ import { import { readLimitedJson } from "../../../lib/server/validation/requestBody"; 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) => { if (!isDatabaseConfigured()) { return dbUnavailable(); @@ -36,8 +48,12 @@ export const GET = apiRoute("rules.list", async (request: NextRequest) => { const { searchParams } = new URL(request.url); 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({ + where: { userId: { not: null } }, orderBy: [{ updatedAt: "desc" }, { id: "asc" }], take, select: { @@ -60,9 +76,6 @@ export const POST = apiRoute( } const user = await getSessionUser(); - if (!user) { - return unauthorized(); - } const parsedBody = await readLimitedJson(request); if (parsedBody.ok === false) { @@ -75,16 +88,65 @@ export const POST = apiRoute( } 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( stakeholderEmails, user.email, ); if (inviteEmails.length > 0) { - const ip = - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? - request.headers.get("x-real-ip") ?? - "unknown"; + const ip = clientIp(request); const rl = rateLimitKey(`publish-stakeholders-ip:${ip}`, 60_000); if (rl.ok === false) { return rateLimited(rl.retryAfterMs); diff --git a/app/components/modals/Login/Login.view.tsx b/app/components/modals/Login/Login.view.tsx index 6a1ae2f..3796dae 100644 --- a/app/components/modals/Login/Login.view.tsx +++ b/app/components/modals/Login/Login.view.tsx @@ -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}`} onClick={(e) => e.stopPropagation()} > - +
{children}
diff --git a/docs/create-flow.md b/docs/create-flow.md index 0295620..d75cdd5 100644 --- a/docs/create-flow.md +++ b/docs/create-flow.md @@ -57,11 +57,11 @@ Wizard step → React screen rendering lives in [`createFlowScreenComponents.tsx ### 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) -**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. - **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. -**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. @@ -103,10 +103,10 @@ Only one `?fromFlow=1` marker exists, on one hop (`/create/review` → `/templat ## 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)). | -| **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. | +| **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. **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. diff --git a/docs/guides/backend-roadmap.md b/docs/guides/backend-roadmap.md index 1452f88..dfc5004 100644 --- a/docs/guides/backend-roadmap.md +++ b/docs/guides/backend-roadmap.md @@ -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: - **`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). -- **`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). +- **`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 **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. - **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. diff --git a/lib/server/guestRuleClaim.ts b/lib/server/guestRuleClaim.ts new file mode 100644 index 0000000..0966ee7 --- /dev/null +++ b/lib/server/guestRuleClaim.ts @@ -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 { + 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 { + 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 { + 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; + } +} diff --git a/lib/server/hash.ts b/lib/server/hash.ts index b771853..2464f14 100644 --- a/lib/server/hash.ts +++ b/lib/server/hash.ts @@ -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"); } diff --git a/lib/server/session.ts b/lib/server/session.ts index ecdc75a..03c5867 100644 --- a/lib/server/session.ts +++ b/lib/server/session.ts @@ -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 }; } diff --git a/messages/en/create/reviewAndComplete/completed.json b/messages/en/create/reviewAndComplete/completed.json index e0f2c0e..7c18017 100644 --- a/messages/en/create/reviewAndComplete/completed.json +++ b/messages/en/create/reviewAndComplete/completed.json @@ -14,5 +14,9 @@ "exportFailedTitle": "Could not export", "exportFailedDescription": "Something went wrong. Refresh the page and try again.", "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." } diff --git a/messages/en/pages/cookies.json b/messages/en/pages/cookies.json index 075e490..7416759 100644 --- a/messages/en/pages/cookies.json +++ b/messages/en/pages/cookies.json @@ -2,19 +2,20 @@ "_comment": "Cookies Settings. Figma Content page Template (19003:23305): banner + labeled body stacks. There are no optional cookies to toggle.", "banner": { "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", "date": "2026-08-15" }, "updated": "Last updated August 15, 2026.", "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": [ { "heading": "The session cookie", "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." ] }, { diff --git a/messages/en/pages/privacy.json b/messages/en/pages/privacy.json index 993e777..5d58890 100644 --- a/messages/en/pages/privacy.json +++ b/messages/en/pages/privacy.json @@ -14,7 +14,7 @@ { "heading": "What we collect", "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." ] }, diff --git a/prisma/migrations/20260826160000_add_published_rule_claim_token/migration.sql b/prisma/migrations/20260826160000_add_published_rule_claim_token/migration.sql new file mode 100644 index 0000000..2a9f6fc --- /dev/null +++ b/prisma/migrations/20260826160000_add_published_rule_claim_token/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "PublishedRule" ADD COLUMN "claimTokenHash" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "PublishedRule_claimTokenHash_key" ON "PublishedRule"("claimTokenHash"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e145794..1560cdf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -79,6 +79,9 @@ model PublishedRule { document Json createdAt DateTime @default(now()) 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[] diff --git a/tests/components/Login.test.tsx b/tests/components/Login.test.tsx index c8608b7..abcea52 100644 --- a/tests/components/Login.test.tsx +++ b/tests/components/Login.test.tsx @@ -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( + +

Body

+
, + ); + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + expect(screen.getByLabelText("Close dialog")).toBeInTheDocument(); + expect(screen.queryByLabelText("More options")).not.toBeInTheDocument(); + }); }); diff --git a/tests/pages/legal.test.tsx b/tests/pages/legal.test.tsx index 678d6ad..15aece6 100644 --- a/tests/pages/legal.test.tsx +++ b/tests/pages/legal.test.tsx @@ -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"); diff --git a/tests/unit/guestRuleClaim.test.ts b/tests/unit/guestRuleClaim.test.ts new file mode 100644 index 0000000..5a17ed8 --- /dev/null +++ b/tests/unit/guestRuleClaim.test.ts @@ -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 }), + ); + }); +}); diff --git a/tests/unit/hashRuleClaimToken.test.ts b/tests/unit/hashRuleClaimToken.test.ts new file mode 100644 index 0000000..0e2ac70 --- /dev/null +++ b/tests/unit/hashRuleClaimToken.test.ts @@ -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); + }); +}); diff --git a/tests/unit/hooks/useCreateFlowFinalize.test.tsx b/tests/unit/hooks/useCreateFlowFinalize.test.tsx index d2cdfff..2ff6323 100644 --- a/tests/unit/hooks/useCreateFlowFinalize.test.tsx +++ b/tests/unit/hooks/useCreateFlowFinalize.test.tsx @@ -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, }), ); diff --git a/tests/unit/rulesListRoute.test.ts b/tests/unit/rulesListRoute.test.ts new file mode 100644 index 0000000..813fa49 --- /dev/null +++ b/tests/unit/rulesListRoute.test.ts @@ -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) }]); + }); +}); diff --git a/tests/unit/rulesPublishPostRoute.test.ts b/tests/unit/rulesPublishPostRoute.test.ts index eadd849..99ecc0e 100644 --- a/tests/unit/rulesPublishPostRoute.test.ts +++ b/tests/unit/rulesPublishPostRoute.test.ts @@ -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(); + }); }); diff --git a/tests/unit/sessionLifecycle.test.ts b/tests/unit/sessionLifecycle.test.ts index 0431072..3c35fca 100644 --- a/tests/unit/sessionLifecycle.test.ts +++ b/tests/unit/sessionLifecycle.test.ts @@ -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 } };