Comentários moderados em qualquer entidade: visitante ou usuário, respostas, nota 1–5, bloqueios, LGPD e HTML seguro
Instalar
genpm add @core/commentsO que você recebe
- Código em src/lib/comments/, 9 arquivos. (23 kB)
- Regras de IA em src/lib/comments/AGENTS.md, mais arquivos de regras da IDE.
- Variáveis de ambiente adicionadas ao .env.example: COMMENTS_NOTIFY_TO.
- Resolve @core/antispam, @core/auth, @core/contracts, @core/db, @core/email, @core/jobs para você.
README
Este pacote não tem README.
Isto é exatamente o que sua IA lê quando trabalha em src/lib/comments. Nada mais é adicionado ao contexto dela.
@core/comments — rules for AI agents
Purpose
Comments on anything (entityType + entityId: posts, products, pages): guests (name + email, stored only as a hash)
or signed-in users, one level of replies, optional 1–5 rating (reused by @core/reviews), moderation queue with email
alerts, bans, GDPR erasure and safe HTML rendering. Anti-spam via @core/antispam. Tables: comments, comment_bans.
Map
index.ts— public API:postComment,listComments,moderate,ban,eraseComments,ratingSummary,commentHtml,commentsAdminResource.comments.ts— logic and thecomments.notifyjob.admin.ts— moderation queue.schema.ts— tables.adapters/hono.ts—commentRoutes({ currentUser }).adapters/next.ts—commentRoute({ currentUser })→{ GET, POST }.
Integration
- Env: optional
COMMENTS_NOTIFY_TO(moderators, comma-separated) andEMAIL_FROM. Migrations as insrc/lib/db/AGENTS.md; the @core/jobs cron must run. - Mount the endpoints at
/api/comments/:type/:idwithcurrentUserfrom @core/auth (getUser/getUserFromCookieHeader). - The form posts
body(andname,emailfor guests,parentIdfor replies) plus the @core/antispam fields (honeypotProps,_ts). Show "your comment awaits moderation" when the response status ispending. - Render the list from
GETorlistComments(type, id); render each body withcommentHtml(body)(escaped; the only HTML allowed). - Add
commentsAdminResourcetosrc/genpm/admin.ts(permissionscomments:read,comments:moderate). - Verify: a guest comment is pending until approved in the admin, then it is listed.
Conventions
- Default moderation is
guests: guests and users without an approved comment wait for approval. Usenoneonly on trusted, private sites. - Erase on request with
eraseComments({ email })or({ userId }); delete user comments when the account is deleted. - Show ratings only from
ratingSummary(approved comments).
Don't
- Don't store or show guest emails; don't render comment bodies as Markdown/HTML.
- Don't auto-approve guests to "increase engagement".
- Don't expose
pending,spamordeletedcomments in public endpoints.
# @core/comments — rules for AI agents
## Purpose
Comments on anything (`entityType` + `entityId`: posts, products, pages): guests (name + email, stored only as a hash)
or signed-in users, one level of replies, optional 1–5 rating (reused by @core/reviews), moderation queue with email
alerts, bans, GDPR erasure and safe HTML rendering. Anti-spam via @core/antispam. Tables: `comments`, `comment_bans`.
## Map
- `index.ts` — public API: `postComment`, `listComments`, `moderate`, `ban`, `eraseComments`, `ratingSummary`, `commentHtml`, `commentsAdminResource`.
- `comments.ts` — logic and the `comments.notify` job. `admin.ts` — moderation queue. `schema.ts` — tables.
- `adapters/hono.ts` — `commentRoutes({ currentUser })`. `adapters/next.ts` — `commentRoute({ currentUser })` → `{ GET, POST }`.
## Integration
1. Env: optional `COMMENTS_NOTIFY_TO` (moderators, comma-separated) and `EMAIL_FROM`. Migrations as in `src/lib/db/AGENTS.md`; the @core/jobs cron must run.
2. Mount the endpoints at `/api/comments/:type/:id` with `currentUser` from @core/auth (`getUser`/`getUserFromCookieHeader`).
3. The form posts `body` (and `name`, `email` for guests, `parentId` for replies) plus the @core/antispam fields (`honeypotProps`, `_ts`).
Show "your comment awaits moderation" when the response status is `pending`.
4. Render the list from `GET` or `listComments(type, id)`; render each body with `commentHtml(body)` (escaped; the only HTML allowed).
5. Add `commentsAdminResource` to `src/genpm/admin.ts` (permissions `comments:read`, `comments:moderate`).
6. Verify: a guest comment is pending until approved in the admin, then it is listed.
## Conventions
- Default moderation is `guests`: guests and users without an approved comment wait for approval. Use `none` only on trusted, private sites.
- Erase on request with `eraseComments({ email })` or `({ userId })`; delete user comments when the account is deleted.
- Show ratings only from `ratingSummary` (approved comments).
## Don't
- Don't store or show guest emails; don't render comment bodies as Markdown/HTML.
- Don't auto-approve guests to "increase engagement".
- Don't expose `pending`, `spam` or `deleted` comments in public endpoints.
A árvore exata que será injetada, após o .genpmignore. Fixada em
// Publicar, listar, moderar y valorar. Invitados siempre a moderación; usuarios con historial aprobado, directos.
import { and, asc, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifyHuman } from '../antispam/index.ts';
import { type Executor, getDb } from '../db/index.ts';
import { getEmailProvider } from '../email/index.ts';
import { defineJob } from '../jobs/index.ts';
import { type Comment, commentBans, comments } from './schema.ts';
export class CommentError extends Error {
constructor(
readonly code: 'spam' | 'rate_limited' | 'invalid' | 'banned' | 'not_found' | 'forbidden',
message: string = code,
) {
super(message);
this.name = 'CommentError';
}
}
const enc = new TextEncoder();
export async function emailHash(email: string): Promise<string> {
const buf = await crypto.subtle.digest('SHA-256', enc.encode(email.trim().toLowerCase()));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
const ENTITY_RE = /^[a-z][a-z0-9_-]{0,31}$/;
const Input = z.object({
entityType: z.string().regex(ENTITY_RE),
entityId: z.string().min(1).max(64),
parentId: z.string().max(64).optional(),
body: z.string().trim().min(2).max(5000),
rating: z.coerce.number().int().min(1).max(5).optional(),
});
const Guest = z.object({ name: z.string().trim().min(1).max(80), email: z.email().max(254) });
export type PostCommentInput = z.input<typeof Input> & {
/** Usuario con sesión (de @core/auth) o datos del invitado. */
user?: { id: string; name: string | null } | null;
guest?: { name: string; email: string };
};
export type Moderation = 'guests' | 'all' | 'none';
/**
* Publica un comentario. Comprueba antispam (`fields` del formulario), bloqueos y que la respuesta sea a un comentario
* aprobado de la misma entidad. Estado inicial según `moderation`: `guests` (default) modera invitados y usuarios
* sin ningún comentario aprobado previo.
*/
export async function postComment(
input: PostCommentInput,
ctx: { headers: Headers; fields: FormData | Record<string, unknown>; moderation?: Moderation; skipAntispam?: boolean },
db: Executor = getDb(),
): Promise<Comment> {
if (!ctx.skipAntispam) {
const human = await verifyHuman(ctx.headers, ctx.fields, { scope: 'comments', limit: 5, windowMs: 600_000 }, db);
if (!human.ok) throw new CommentError(human.reason === 'rate_limited' ? 'rate_limited' : 'spam');
}
const parsed = Input.safeParse(input);
if (!parsed.success) throw new CommentError('invalid', parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '));
const data = parsed.data;
let authorName: string;
let hash: string | null = null;
if (input.user) {
authorName = (input.user.name ?? '').trim().slice(0, 80) || 'Anonymous';
if (await isBanned(`user:${input.user.id}`, db)) throw new CommentError('banned');
} else {
const g = Guest.safeParse(input.guest);
if (!g.success) throw new CommentError('invalid', 'guest name and email are required');
authorName = g.data.name;
hash = await emailHash(g.data.email);
if (await isBanned(`email:${hash}`, db)) throw new CommentError('banned');
}
if (data.parentId) {
const [parent] = await db.select().from(comments).where(eq(comments.id, data.parentId));
if (!parent || parent.entityType !== data.entityType || parent.entityId !== data.entityId || parent.parentId || parent.status !== 'approved')
throw new CommentError('invalid', 'invalid parent comment');
}
const moderation = ctx.moderation ?? 'guests';
let status: Comment['status'] = 'pending';
if (moderation === 'none') status = 'approved';
else if (moderation === 'guests' && input.user) {
const [prev] = await db.select({ n: count() }).from(comments).where(and(eq(comments.authorId, input.user.id), eq(comments.status, 'approved')));
if ((prev?.n ?? 0) > 0) status = 'approved';
}
const [row] = await db
.insert(comments)
.values({ ...data, rating: data.rating ?? null, parentId: data.parentId ?? null, authorId: input.user?.id ?? null, authorName, guestEmailHash: hash, status })
.returning();
if (status === 'pending') await notifyModeratorsJob.enqueue({ commentId: row!.id }, {}, db);
return row!;
}
async function isBanned(subject: string, db: Executor): Promise<boolean> {
const [b] = await db.select({ id: commentBans.id }).from(commentBans).where(eq(commentBans.subject, subject));
return !!b;
}
export type PublicComment = Pick<Comment, 'id' | 'authorName' | 'body' | 'rating' | 'createdAt'> & { replies: Array<Pick<Comment, 'id' | 'authorName' | 'body' | 'createdAt'>> };
/** Comentarios aprobados de una entidad: los de primer nivel paginados, cada uno con sus respuestas. */
export async function listComments(
entityType: string,
entityId: string,
opts: { page?: number; perPage?: number; order?: 'newest' | 'oldest' } = {},
db: Executor = getDb(),
): Promise<{ comments: PublicComment[]; total: number }> {
const perPage = Math.min(Math.max(opts.perPage ?? 20, 1), 100);
const page = Math.max(opts.page ?? 1, 1);
const base = and(eq(comments.entityType, entityType), eq(comments.entityId, entityId), eq(comments.status, 'approved'));
const [total] = await db.select({ n: count() }).from(comments).where(and(base, isNull(comments.parentId)));
const top = await db
.select()
.from(comments)
.where(and(base, isNull(comments.parentId)))
.orderBy(opts.order === 'oldest' ? asc(comments.createdAt) : desc(comments.createdAt), asc(comments.id))
.limit(perPage)
.offset((page - 1) * perPage);
const replies = top.length
? await db.select().from(comments).where(and(base, inArray(comments.parentId, top.map((c) => c.id)))).orderBy(asc(comments.createdAt), asc(comments.id))
: [];
const pick = (c: Comment) => ({ id: c.id, authorName: c.authorName, body: c.body, createdAt: c.createdAt });
return {
total: total?.n ?? 0,
comments: top.map((c) => ({ ...pick(c), rating: c.rating, replies: replies.filter((r) => r.parentId === c.id).map(pick) })),
};
}
export type ModerationAction = 'approve' | 'spam' | 'delete' | 'restore';
/** Cambia el estado (`comments:moderate`). `restore` vuelve a `pending`. */
export async function moderate(id: string, action: ModerationAction, db: Executor = getDb()): Promise<Comment> {
const status = { approve: 'approved', spam: 'spam', delete: 'deleted', restore: 'pending' } as const;
const [row] = await db.update(comments).set({ status: status[action] }).where(eq(comments.id, id)).returning();
if (!row) throw new CommentError('not_found');
return row;
}
/** Bloquea a un usuario o a un email de invitado y marca como spam sus comentarios pendientes. */
export async function ban(target: { userId: string } | { email: string } | { commentId: string }, reason?: string, db: Executor = getDb()): Promise<void> {
let subject: string;
if ('commentId' in target) {
const [c] = await db.select().from(comments).where(eq(comments.id, target.commentId));
if (!c) throw new CommentError('not_found');
subject = c.authorId ? `user:${c.authorId}` : `email:${c.guestEmailHash}`;
} else subject = 'userId' in target ? `user:${target.userId}` : `email:${await emailHash(target.email)}`;
await db.insert(commentBans).values({ subject, reason: reason ?? null }).onConflictDoNothing();
const sep = subject.indexOf(':');
const [kind, value] = [subject.slice(0, sep), subject.slice(sep + 1)];
await db
.update(comments)
.set({ status: 'spam' })
.where(and(eq(comments.status, 'pending'), kind === 'user' ? eq(comments.authorId, value) : eq(comments.guestEmailHash, value)));
}
/** Derecho de supresión: borra todos los comentarios de un email de invitado o de un usuario. */
export async function eraseComments(target: { email: string } | { userId: string }, db: Executor = getDb()): Promise<number> {
const where = 'email' in target ? eq(comments.guestEmailHash, await emailHash(target.email)) : eq(comments.authorId, target.userId);
const rows = await db.delete(comments).where(where).returning({ id: comments.id });
return rows.length;
}
export type RatingSummary = { count: number; average: number; histogram: [number, number, number, number, number] };
/** Media y distribución de valoraciones aprobadas de una entidad. */
export async function ratingSummary(entityType: string, entityId: string, db: Executor = getDb()): Promise<RatingSummary> {
const rows = await db
.select({ rating: comments.rating, n: count() })
.from(comments)
.where(and(eq(comments.entityType, entityType), eq(comments.entityId, entityId), eq(comments.status, 'approved'), sql`${comments.rating} is not null`))
.groupBy(comments.rating);
const histogram: RatingSummary['histogram'] = [0, 0, 0, 0, 0];
for (const r of rows) histogram[r.rating! - 1] = r.n;
const total = histogram.reduce((a, b) => a + b, 0);
const sum = histogram.reduce((a, n, i) => a + n * (i + 1), 0);
return { count: total, average: total ? Math.round((sum / total) * 100) / 100 : 0, histogram };
}
const esc = (s: string) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
/** HTML seguro de un comentario: escapado, saltos de línea y enlaces http(s) con `rel="nofollow ugc noopener"`. */
export function commentHtml(body: string): string {
return esc(body)
.replace(/https?:\/\/[^\s<]+[^\s<.,;:!?)\]'"]/g, (url) => `<a href="${url}" rel="nofollow ugc noopener" target="_blank">${url}</a>`)
.replace(/\r?\n/g, '<br>');
}
/** Aviso por email a moderación (`COMMENTS_NOTIFY_TO`) de cada comentario pendiente. */
export const notifyModeratorsJob = defineJob('comments.notify', z.object({ commentId: z.string() }), async ({ commentId }) => {
const to = (process.env.COMMENTS_NOTIFY_TO ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const from = process.env.EMAIL_FROM;
if (!to.length || !from) return;
const [c] = await getDb().select().from(comments).where(eq(comments.id, commentId));
if (!c || c.status !== 'pending') return;
await getEmailProvider().send({
from,
to,
subject: `New comment to moderate on ${c.entityType} ${c.entityId}`,
text: `${c.authorName} wrote:\n\n${c.body}`,
html: `<p><strong>${esc(c.authorName)}</strong> wrote:</p><p>${commentHtml(c.body)}</p>`,
});
});
Este pacote não declara servidores MCP.
| Versão | Commit | Publicado | Análise |
|---|---|---|---|
| 1.0.1 | abfbbe8 | há 4 horas | análise aprovada |
- genpm
- @core/antispam ^1.0.0@core/auth ^1.1.0@core/contracts ^1.0.0@core/db ^1.0.0@core/email ^1.0.1@core/jobs ^1.0.0
- npm
- zod ^4.0.0
- proposto
- O GenPM propõe o comando npm e só o executa se você disser sim.
- Usado por (2)
- @core/kit-blog ^1.0.0@core/reviews ^1.0.0
- análise
- análise aprovada · 0 achados
- commit
- v1.0.1 → abfbbe8ddb3706a9640b94d9dea43a45e70da1a9 · verificado após o download
- scripts
- Nenhum. O GenPM nunca executa código de pacotes.
- licença
- MIT
- Qualidade
- 100/100
- Licença reconhecidacumprido
- AGENTS.md explica o propósitocumprido
- AGENTS.md tem passos de integraçãocumprido
- AGENTS.md lista convenções ou proibiçõescumprido
- Inclui testescumprido
- Escaneamento de segurança aprovadocumprido
- Publicado nos últimos 6 mesescumprido
- Publicador verificadocumprido
- Resumo e palavras-chavecumprido
- denúncia
- Viu algo errado?