Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e01e192
Fix CodeRabbit security, correctness, and performance findings
claude Aug 20, 2026
5ebf372
Add JSDoc to all functions for 100% docstring coverage
claude Aug 20, 2026
074ab12
Add JSDoc to remaining functions for 100% docstring coverage
claude Aug 20, 2026
a420ada
fix: restrict moderateGuestbookEntry to pending entries only
claude Aug 20, 2026
08f25a3
docs: add JSDoc to all remaining undocumented functions in diff scope
claude Aug 20, 2026
5d70d1f
docs: add JSDoc to remaining StudioClient inner action handlers
claude Aug 20, 2026
be65b1d
Add JSDoc to all webRings.ts exports for 100% docstring coverage
claude Aug 21, 2026
a4c161b
Fix publishExistingDraft JSDoc: clarify it updates editor state to pu…
claude Aug 21, 2026
660884b
Add JSDoc to remaining undocumented functions for 100% coverage
claude Aug 21, 2026
57fa910
Fix commitEdit JSDoc: clarifies current doc is saved to undo stack, n…
claude Aug 21, 2026
3136418
Add JSDoc to ctxWithStatus test helper in moduleRegistry.test.ts for …
claude Aug 21, 2026
a92870c
Add comprehensive mobile/tablet responsive styles (768px, 640px, 400p…
claude Aug 21, 2026
231c7e8
Add JSDoc to useMemo hooks in StudioClient for 100% docstring coverage
claude Aug 21, 2026
6d149c7
Fix all CodeRabbit findings: privacy, correctness, and test coverage
claude Aug 21, 2026
b8dcc90
Add Next.js agent rule files generated by dev server
claude Aug 21, 2026
919e839
UI overhaul: black/white minimalist graffiti theme + messenger redesign
claude Aug 21, 2026
2e00bc5
Self-host Bebas Neue font for reliable rendering
claude Aug 21, 2026
8433f5a
Centered logo nav + Vandal Blow Graffiti font wiring
claude Aug 21, 2026
cead9e5
Add graffiti logo image and Vandal Blow font files
claude Aug 21, 2026
773aa5e
Add playwright dev dependency for screenshot testing
claude Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions app/.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ out
*.db-journal
*.db-wal
*.db-shm
.env
.env.local
.env*
coverage
.git
2 changes: 1 addition & 1 deletion app/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Multi-stage build for a small, self-contained production image.
# Needs Node 22.5+ for node:sqlite (same requirement as local dev).

FROM node:22-slim AS base
FROM node:22.13-slim AS base

FROM base AS deps
WORKDIR /app
Expand Down
2 changes: 1 addition & 1 deletion app/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const nextConfig: NextConfig = {
// it's running on unless that origin is explicitly allowed here.
experimental: {
serverActions: {
allowedOrigins: ["localhost:3000", "*.app.github.dev", "*.trycloudflare.com"],
allowedOrigins: ["localhost:3000", "*.trycloudflare.com"],
},
},
};
Expand Down
1 change: 1 addition & 0 deletions app/src/app/(platform)/asks/AnswerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { answerAskAction, type AskActionState } from "./actions";

const initialState: AskActionState = {};

/** Form that lets the page owner submit an answer to a specific ask. */
export function AnswerForm({ askId }: { askId: string }) {
const boundAction = answerAskAction.bind(null, askId);
const [state, formAction, pending] = useActionState(boundAction, initialState);
Expand Down
1 change: 1 addition & 0 deletions app/src/app/(platform)/asks/AskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function formatDate(iso: string): string {
return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}

/** Renders a single ask with its answers and, for the page owner, an answer form. */
export function AskCard({
ask,
answers,
Expand Down
1 change: 1 addition & 0 deletions app/src/app/(platform)/asks/AskForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createAskAction, type AskActionState } from "./actions";

const initialState: AskActionState = {};

/** Form that lets a viewer submit a new ask to the current page owner. */
export function AskForm() {
const [state, formAction, pending] = useActionState(createAskAction, initialState);

Expand Down
7 changes: 5 additions & 2 deletions app/src/app/(platform)/asks/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ export interface AskActionState {
success?: string;
}

/** Maps AskError and RateLimitError to user-facing state; re-throws anything else. */
function handleAskError(e: unknown): never | AskActionState {
if (e instanceof AskError) return { error: e.message };
if (e instanceof RateLimitError) return { error: e.message };
throw e;
}

/** Server action: validate and submit a new ask for the signed-in viewer. */
export async function createAskAction(
_prevState: AskActionState,
formData: FormData,
Expand All @@ -36,8 +38,6 @@ export async function createAskAction(
const isSensitive = formData.get("sensitive") === "on";

try {
const key = await rateLimitActorKey("ask-view", viewer.id);
checkRateLimit(key, 20);
createAsk({ askerId: viewer.id, body, domain, isAnonymous, isSensitive });
} catch (e) {
return handleAskError(e);
Expand All @@ -48,6 +48,7 @@ export async function createAskAction(
return { success: "Your ask is posted." };
}

/** Server action: submit an answer to *askId* from the signed-in viewer. */
export async function answerAskAction(
askId: string,
_prevState: AskActionState,
Expand All @@ -70,6 +71,7 @@ export async function answerAskAction(
return { success: "Your answer is posted." };
}

/** Server action: close *askId* — only the original asker may do this. */
export async function closeAskAction(askId: string): Promise<void> {
const viewer = await getCurrentUser();
if (!viewer) redirect("/login?next=/asks/mine");
Expand All @@ -85,6 +87,7 @@ export async function closeAskAction(askId: string): Promise<void> {
revalidatePath("/asks/mine");
}

/** Server action: opt the signed-in viewer in or out of the ask pool. */
export async function setReachableAction(reachable: boolean): Promise<void> {
const viewer = await getCurrentUser();
if (!viewer) redirect("/login?next=/asks");
Expand Down
1 change: 1 addition & 0 deletions app/src/app/(platform)/asks/mine/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { listAnswers, listAsksByUser } from "@/lib/asks";
import { getCurrentUser } from "@/lib/session";
import { closeAskAction } from "../actions";

/** Server page showing the signed-in user's own asks and their statuses. */
export default async function MyAsksPage() {
const viewer = await getCurrentUser();
if (!viewer) redirect("/login?next=/asks/mine");
Expand Down
1 change: 1 addition & 0 deletions app/src/app/(platform)/asks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { setReachableAction } from "./actions";
import { AskCard } from "./AskCard";
import { AskForm } from "./AskForm";

/** Server page rendering the public Ask Us pool, with the ask form for signed-in users. */
export default async function AsksPage() {
const viewer = await getCurrentUser();
if (!viewer) redirect("/login?next=/asks");
Expand Down
1 change: 1 addition & 0 deletions app/src/app/(platform)/explore/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface Props {
searchParams: Promise<{ q?: string }>;
}

/** Server page for the public Explore/discovery feed, with tag and text search. */
export default async function ExplorePage({ searchParams }: Props) {
ensureSeedRings();
ensureSeedCollections();
Expand Down
1 change: 1 addition & 0 deletions app/src/app/(platform)/make/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface MakeState {
error?: string;
}

/** Server action for the page-creation wizard. Validates template and display name, builds and saves the page document, then redirects to the new profile. */
export async function makeFlowAction(_prevState: MakeState, formData: FormData): Promise<MakeState> {
const viewer = await getCurrentUser();
if (!viewer) redirect("/login?next=/make");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { sendMessageAction, type SendMessageState } from "../actions";

const initialState: SendMessageState = {};

/** Message-composition form for a direct-message thread with *recipientHandle*. */
export function ThreadComposer({ recipientHandle }: { recipientHandle: string }) {
const boundAction = sendMessageAction.bind(null, recipientHandle);
const [state, formAction, pending] = useActionState(boundAction, initialState);
Expand Down
1 change: 1 addition & 0 deletions app/src/app/(platform)/messages/[handle]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface Props {
params: Promise<{ handle: string }>;
}

/** Server page rendering the direct-message thread between the signed-in user and the profile at *handle*. */
export default async function MessageThreadPage({ params }: Props) {
const { handle: rawParam } = await params;
const viewer = await getCurrentUser();
Expand Down
2 changes: 2 additions & 0 deletions app/src/app/(platform)/messages/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface SendMessageState {
error?: string;
}

/** Server action: send a message to *recipientHandle* from the signed-in viewer. */
export async function sendMessageAction(
recipientHandle: string,
_prevState: SendMessageState,
Expand Down Expand Up @@ -38,6 +39,7 @@ export async function sendMessageAction(
return {};
}

/** Server action: mark all unread messages in *conversationId* as read for the viewer. */
export async function markConversationReadAction(conversationId: string): Promise<void> {
const viewer = await getCurrentUser();
if (!viewer) redirect("/login?next=/messages");
Expand Down
1 change: 1 addition & 0 deletions app/src/app/(platform)/messages/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Link from "next/link";
import { getCurrentUser } from "@/lib/session";
import { listConversations } from "@/lib/messages";

/** Server page listing all direct-message conversations for the signed-in user. */
export default async function MessagesPage() {
const viewer = await getCurrentUser();
if (!viewer) redirect("/login?next=/messages");
Expand Down
1 change: 1 addition & 0 deletions app/src/app/(platform)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const STEPS = [
{ label: "Wander", desc: "Discover other pages, no feed required" },
];

/** Server page rendering the platform landing page — signed-in users see their dashboard, visitors see the public feature overview. */
export default async function HomePage() {
const viewer = await getCurrentUser();

Expand Down
1 change: 1 addition & 0 deletions app/src/app/(platform)/studio/StudioClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ function defaultPixelArtPiece(): PixelArtPiece {
};
}

/** Interactive client component for the page editor Studio, managing draft state, live preview, and all module editors. */
export function StudioClient({
initialDocument,
publishedDocument,
Expand Down
1 change: 1 addition & 0 deletions app/src/app/[handle]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function resolveTopEight(handles: string[]): TopEightLink[] {
return links;
}

/** Server page for a user's public profile at `/@handle`, enforcing visibility and block rules before rendering. */
export default async function ProfilePage({ params, searchParams }: Props) {
const { handle: rawParam } = await params;
const { reader, preview } = await searchParams;
Expand Down
2 changes: 1 addition & 1 deletion app/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2575,7 +2575,7 @@ select {
margin: 0;
font-size: 0.9rem;
line-height: 1.4;
word-break: break-word;
overflow-wrap: anywhere;
}

.aim-msg-from {
Expand Down
26 changes: 19 additions & 7 deletions app/src/components/PageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface Props {
topEightLinks: TopEightLink[];
}

/** Renders all enabled page modules for a user's profile page, applying the stored theme as inline CSS variables. */
export function PageRenderer({
document,
friends,
Expand All @@ -35,13 +36,24 @@ export function PageRenderer({
"--page-bg": document.theme.background,
"--page-ink": ink,
"--page-ink-soft": inkSoft,
...(document.theme.backgroundImageUrl
? {
"--page-bg-image": `url("${document.theme.backgroundImageUrl}")`,
"--page-bg-repeat": document.theme.backgroundTile ? "repeat" : "no-repeat",
"--page-bg-size": document.theme.backgroundTile ? "auto" : "cover",
}
: {}),
...(() => {
const raw = document.theme.backgroundImageUrl;
if (!raw) return {};
let href: string;
try {
// Normalize through URL to strip any funny business, then
// encode backslashes so CSS hex-escape sequences in the href
// (e.g. \000022 → ") can't break out of the quoted url().
href = new URL(raw).href.replace(/\\/g, "%5C");
} catch {
return {};
}
return {
"--page-bg-image": `url("${href}")`,
"--page-bg-repeat": document.theme.backgroundTile ? "repeat" : "no-repeat",
"--page-bg-size": document.theme.backgroundTile ? "auto" : "cover",
};
})(),
} as React.CSSProperties);

const bodyClasses = [
Expand Down
7 changes: 4 additions & 3 deletions app/src/components/SiteNav.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Link from "next/link";
import { getCurrentUser } from "@/lib/session";
import { isModerator } from "@/lib/moderation";
import { listIncomingRequests } from "@/lib/friends";
import { listPendingGuestbookEntries } from "@/lib/guestbook";
import { countIncomingRequests } from "@/lib/friends";
import { countPendingGuestbookEntries } from "@/lib/guestbook";
import { countUnreadMessages } from "@/lib/messages";

// Six screens, per the plan: Explore, Make, Ask Us, Messages, Studio,
Expand All @@ -21,11 +21,12 @@ import { countUnreadMessages } from "@/lib/messages";
// until you happen to click in — confirmed as a real dead end during
// live testing, not a hypothetical. These are count badges on existing
// links, not a notification center.
/** Server component that renders the site navigation bar with pending-activity badges for the signed-in user. */
export async function SiteNav() {
const viewer = await getCurrentUser();
const moderator = viewer ? isModerator(viewer.id) : false;
const pendingCount = viewer
? listIncomingRequests(viewer.id).length + listPendingGuestbookEntries(viewer.id).length
? countIncomingRequests(viewer.id) + countPendingGuestbookEntries(viewer.id)
: 0;
const unreadMessages = viewer ? countUnreadMessages(viewer.id) : 0;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down
49 changes: 42 additions & 7 deletions app/src/lib/asks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const POOL_SIZE = 8;

export interface Ask {
id: string;
askerId: string;
askerId: string | null; // null for anonymous asks when viewer is not the owner
askerHandle: string | null; // null when anonymous — never leaked to non-owners
body: string;
domain: string | null;
Expand Down Expand Up @@ -54,7 +54,7 @@ function rowToAsk(row: AskRow, viewerId: string | null): Ask {
const anonymous = row.is_anonymous === 1;
return {
id: row.id,
askerId: row.asker_id,
askerId: anonymous && !isOwner ? null : row.asker_id,
askerHandle: anonymous && !isOwner ? null : row.asker_handle,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
body: row.body,
domain: row.domain,
Expand All @@ -73,11 +73,20 @@ const SELECT_ASK = `
FROM asks a JOIN users u ON u.id = a.asker_id
`;

/** Opt in or out of being reachable by strangers' asks. Off by default. */
/** Opt in or out of being reachable by strangers' asks. Off by default.
* When disabling, also closes the user's existing open non-sensitive asks
* so they stop appearing in other members' pools immediately. */
export function setReachableForAsks(userId: string, reachable: boolean): void {
getDb().prepare("UPDATE users SET reachable_for_asks = ? WHERE id = ?").run(reachable ? 1 : 0, userId);
const db = getDb();
db.prepare("UPDATE users SET reachable_for_asks = ? WHERE id = ?").run(reachable ? 1 : 0, userId);
if (!reachable) {
db.prepare(
"UPDATE asks SET status = 'closed', closed_at = ? WHERE asker_id = ? AND status = 'open' AND is_sensitive = 0",
).run(new Date().toISOString(), userId);
}
}

/** Returns true if *userId* has opted in to receiving strangers' asks. */
export function isReachableForAsks(userId: string): boolean {
const row = getDb().prepare("SELECT reachable_for_asks FROM users WHERE id = ?").get(userId) as
| { reachable_for_asks: number }
Expand All @@ -93,6 +102,7 @@ export interface CreateAskInput {
isSensitive?: boolean;
}

/** Validate and persist a new ask. Throws AskError on validation failure, RateLimitError when the daily cap is reached. */
export function createAsk(input: CreateAskInput): Ask {
const body = input.body.trim();
if (!body) throw new AskError("Your ask can't be empty.");
Expand Down Expand Up @@ -154,7 +164,7 @@ export function listAsksForViewer(viewerId: string, domain?: string): Ask[] {
.map((row) => rowToAsk(row, viewerId));
}

/** All asks a member has posted, for their own "My asks" view. */
/** All asks *askerId* has posted, for their own "My asks" view, newest first. */
export function listAsksByUser(askerId: string): Ask[] {
const db = getDb();
const rows = db
Expand All @@ -163,6 +173,7 @@ export function listAsksByUser(askerId: string): Ask[] {
return rows.map((row) => rowToAsk(row, askerId));
}

/** Fetch a single ask by id, applying the same anonymity rules as listAsksForViewer. Returns null when not found. */
export function getAsk(askId: string, viewerId: string | null): Ask | null {
const row = getDb().prepare(`${SELECT_ASK} WHERE a.id = ?`).get(askId) as unknown as AskRow | undefined;
return row ? rowToAsk(row, viewerId) : null;
Expand All @@ -177,6 +188,7 @@ export interface Answer {
createdAt: string;
}

/** All answers for *askId*, oldest first. */
export function listAnswers(askId: string): Answer[] {
const rows = getDb()
.prepare(
Expand All @@ -196,6 +208,7 @@ export function listAnswers(askId: string): Answer[] {
}));
}

/** Submit *answererId*'s answer to *askId*. Re-validates eligibility at write time. Throws AskError on auth/validation failure. */
export function answerAsk(askId: string, answererId: string, body: string): Answer {
const trimmed = body.trim();
if (!trimmed) throw new AskError("Your answer can't be empty.");
Expand All @@ -204,8 +217,8 @@ export function answerAsk(askId: string, answererId: string, body: string): Answ
}

const db = getDb();
const ask = db.prepare("SELECT asker_id, status FROM asks WHERE id = ?").get(askId) as
| { asker_id: string; status: string }
const ask = db.prepare("SELECT asker_id, status, is_sensitive FROM asks WHERE id = ?").get(askId) as
| { asker_id: string; status: string; is_sensitive: number }
| undefined;
if (!ask) throw new AskError("This ask no longer exists.");
if (ask.status !== "open") throw new AskError("This ask is closed.");
Expand All @@ -214,6 +227,27 @@ export function answerAsk(askId: string, answererId: string, body: string): Answ
throw new AskError("You can't answer this ask.");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Re-check the same eligibility rules as listAsksForViewer on this write path.
// A user can submit a previously rendered form after their eligibility changes.
if (ask.is_sensitive === 0) {
const reachable = db.prepare("SELECT reachable_for_asks FROM users WHERE id = ?").get(answererId) as
| { reachable_for_asks: number }
| undefined;
if (!reachable || reachable.reachable_for_asks !== 1) {
throw new AskError("You can't answer this ask.");
}
} else {
const friendship = db
.prepare(
`SELECT 1 FROM friend_links WHERE status = 'accepted'
AND ((requester_id = ? AND addressee_id = ?) OR (addressee_id = ? AND requester_id = ?))`,
)
.get(ask.asker_id, answererId, ask.asker_id, answererId);
if (!friendship) {
throw new AskError("You can't answer this ask.");
}
}

const existing = db
.prepare("SELECT 1 FROM ask_answers WHERE ask_id = ? AND answerer_id = ?")
.get(askId, answererId);
Expand All @@ -235,6 +269,7 @@ export function answerAsk(askId: string, answererId: string, body: string): Answ
};
}

/** Close *askId* so it no longer appears in the pool. Only the original asker may do this. Idempotent. */
export function closeAsk(askId: string, askerId: string): void {
const db = getDb();
const row = db.prepare("SELECT asker_id, status FROM asks WHERE id = ?").get(askId) as
Expand Down
Loading