Keep listed published rules public and guest-claim rules unlisted, with semantic document markup and share chrome that stays clickable.

Guest publishes remain share-by-link but stay out of the catalog and sitemap, and their pages send noindex until claimed. The published-rule view now uses heading and list markup matching Markdown export, and top banners sit below the header so Share is not covered.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilalloandCursor committed 2026-09-17 17:42:01 -06:00
1 parent 77b0ec8ad8
commit 1445cb7e1e
27 files changed
+524 -86

No files matched your search

+13 -4
View File
@@ -17,11 +17,13 @@ const STATIC_PUBLIC_PATHS = [
] as const;
/**
* Public marketing URLs for `/sitemap.xml`. Omits signed-in product routes
* (`/create`, `/login`, `/profile`), admin, and `/rules/[id]` (those ids are
* not known without the database).
* Public marketing URLs for `/sitemap.xml`, plus listed published rules when
* the caller supplies them. Omits signed-in product routes (`/create`,
* `/login`, `/profile`), admin, and unlisted guest-claim `/rules/[id]`.
*/
export function buildPublicSitemap(): MetadataRoute.Sitemap {
export function buildPublicSitemap(
listedRules: ReadonlyArray<{ id: string; updatedAt: Date }> = [],
): MetadataRoute.Sitemap {
const entries: MetadataRoute.Sitemap = STATIC_PUBLIC_PATHS.map((path) => ({
url: sitemapUrl(path),
}));
@@ -38,5 +40,12 @@ export function buildPublicSitemap(): MetadataRoute.Sitemap {
entries.push({ url: sitemapUrl(`/use-cases/${slug}/rule`) });
}
for (const rule of listedRules) {
entries.push({
url: sitemapUrl(`/rules/${encodeURIComponent(rule.id)}`),
lastModified: rule.updatedAt,
});
}
return entries;
}
+24
View File
@@ -0,0 +1,24 @@
import type { Prisma } from "@prisma/client";
/**
* Access model for published CommunityRules:
*
* - **Readable without sign-in:** every published row is share-by-link at
* `/rules/[id]` and `GET /api/rules/[id]`.
* - **Listed** (public catalog `GET /api/rules`, sitemap, indexable): owned
* rows, and ownerless rows that are *not* waiting on a guest claim
* (including rules orphaned when an account is deleted).
* - **Unlisted** (omitted from the catalog and sitemap, `noindex`): guest
* publishes with `userId` null and `claimTokenHash` set, until claimed.
*/
export function isListedPublishedRule(row: {
userId: string | null;
claimTokenHash: string | null;
}): boolean {
return row.userId != null || row.claimTokenHash == null;
}
/** Prisma `where` for the public catalog and sitemap. */
export const LISTED_PUBLISHED_RULE_WHERE: Prisma.PublishedRuleWhereInput = {
OR: [{ userId: { not: null } }, { claimTokenHash: null }],
};
+74 -3
View File
@@ -1,9 +1,14 @@
import {
LISTED_PUBLISHED_RULE_WHERE,
isListedPublishedRule,
} from "../publishedRuleAccess";
import { prisma } from "./db";
import { isDatabaseConfigured } from "./env";
/**
* Public fields safe to expose via the unauthenticated rule detail surfaces
* (`GET /api/rules/[id]` and `/rules/[id]`). `userId` is intentionally omitted.
* (`GET /api/rules/[id]` and `/rules/[id]`). `userId` and `claimTokenHash`
* stay server-side; callers get {@link PublicPublishedRule.isListed} instead.
*/
const PUBLISHED_RULE_PUBLIC_SELECT = {
id: true,
@@ -21,6 +26,8 @@ export type PublicPublishedRule = {
document: unknown;
createdAt: Date;
updatedAt: Date;
/** False for guest-claim rows; true for owned and orphaned-after-delete rows. */
isListed: boolean;
};
/**
@@ -39,9 +46,18 @@ export async function getPublicPublishedRuleById(
try {
const rule = await prisma.publishedRule.findUnique({
where: { id },
select: PUBLISHED_RULE_PUBLIC_SELECT,
select: {
...PUBLISHED_RULE_PUBLIC_SELECT,
userId: true,
claimTokenHash: true,
},
});
return rule;
if (!rule) return null;
const { userId, claimTokenHash, ...publicFields } = rule;
return {
...publicFields,
isListed: isListedPublishedRule({ userId, claimTokenHash }),
};
} catch {
return null;
}
@@ -64,6 +80,61 @@ export type OwnerPublishedRuleListItem = {
updatedAt: Date;
};
const LISTED_RULE_SITEMAP_SELECT = {
id: true,
updatedAt: true,
} as const;
const LISTED_PUBLISHED_RULE_SITEMAP_CAP = 10_000;
/**
* Public catalog of listed published rules (**most recently updated first**,
* then stable `id` tie-break). Guest-claim rows are omitted.
* Returns `null` when the database is not configured or the query throws.
*/
export async function listListedPublishedRules(
take: number,
): Promise<OwnerPublishedRuleListItem[] | null> {
if (!isDatabaseConfigured()) return null;
const clamped = Math.min(Math.max(0, take), 100);
if (clamped === 0) return [];
try {
return await prisma.publishedRule.findMany({
where: LISTED_PUBLISHED_RULE_WHERE,
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
take: clamped,
select: PUBLISHED_RULE_OWNER_LIST_SELECT,
});
} catch {
return null;
}
}
/**
* Listed published-rule ids for `/sitemap.xml`. Empty when the database is
* not configured or the query throws (marketing URLs still render).
*/
export async function listListedPublishedRulesForSitemap(
take: number = LISTED_PUBLISHED_RULE_SITEMAP_CAP,
): Promise<Array<{ id: string; updatedAt: Date }>> {
if (!isDatabaseConfigured()) return [];
const clamped = Math.min(
Math.max(0, take),
LISTED_PUBLISHED_RULE_SITEMAP_CAP,
);
if (clamped === 0) return [];
try {
return await prisma.publishedRule.findMany({
where: LISTED_PUBLISHED_RULE_WHERE,
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
take: clamped,
select: LISTED_RULE_SITEMAP_SELECT,
});
} catch {
return [];
}
}
/**
* Lists published rules owned by the given user (**most recently updated first**,
* then stable `id` tie-break).
+10
View File
@@ -12,6 +12,16 @@ const INDEXABLE_HOSTS = new Set([
export const NO_INDEX_ROBOTS = { index: false, follow: false } as const;
/** Unlisted (guest-claim) rule pages stay shareable but must not be indexed. */
export function robotsForPublishedRule(isListed: boolean):
| {
index: false;
follow: false;
}
| undefined {
return isListed ? undefined : NO_INDEX_ROBOTS;
}
function hostnameFromHostHeader(host: string | null | undefined): string {
if (!host) return "";
return host.split(",")[0]?.trim().split(":")[0]?.toLowerCase() ?? "";