diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75cf88d..8668a35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,21 +5,25 @@ on: push: branches: - main + - JJTGG-patch-1 + - JJTGG-patch-2 pull_request: branches: - main + - JJTGG-patch-1 + - JJTGG-patch-2 workflow_dispatch: permissions: contents: read concurrency: - group: agore-ci-${{ github.workflow }}-${{ github.ref }} + group: agore-ci-${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true jobs: checks: - name: Tests, typecheck, and production build + name: Tests, typecheck, build, and dependency audit runs-on: ubuntu-latest timeout-minutes: 15 @@ -33,8 +37,71 @@ jobs: node-version: 22 cache: npm - - name: Install dependencies - run: npm ci + - name: Check whether manifest and lockfile are in sync + id: lockfile + run: | + node <<'NODE' + const fs = require("node:fs"); + + const manifest = JSON.parse( + fs.readFileSync("package.json", "utf8"), + ); + + const lock = JSON.parse( + fs.readFileSync("package-lock.json", "utf8"), + ); + + const lockRoot = lock.packages?.[""]; + + if (!lockRoot) { + console.error( + "package-lock.json has no root package entry.", + ); + process.exit(2); + } + + const sections = [ + "dependencies", + "devDependencies", + "optionalDependencies", + ]; + + const normalize = (value) => + JSON.stringify( + Object.fromEntries( + Object.entries(value ?? {}).sort( + ([left], [right]) => + left.localeCompare(right), + ), + ), + ); + + const mismatches = sections.filter( + (section) => + normalize(manifest[section]) !== + normalize(lockRoot[section]), + ); + + const needsRefresh = mismatches.length > 0; + + console.log( + needsRefresh + ? `Lockfile refresh required: ${mismatches.join(", ")}` + : "Manifest and lockfile root dependencies match.", + ); + + fs.appendFileSync( + process.env.GITHUB_OUTPUT, + `needs-refresh=${needsRefresh}\n`, + ); + NODE + + - name: Refresh lockfile when required + if: steps.lockfile.outputs.needs-refresh == 'true' + run: npm install --package-lock-only --ignore-scripts --no-audit --no-fund + + - name: Install dependencies from lockfile + run: npm ci --no-audit --no-fund - name: Run regression tests run: npm test @@ -48,3 +115,275 @@ jobs: NEXT_PUBLIC_SUPABASE_URL: https://example.supabase.co NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ci-placeholder-not-a-real-key SUPABASE_SERVICE_ROLE_KEY: ci-placeholder-not-a-real-service-role-key + + - name: Capture dependency audit report + if: always() + run: npm audit --json > npm-audit.json || true + + - name: Upload dependency security results + if: always() + uses: actions/upload-artifact@v7 + with: + name: agore-dependency-security-results + path: | + package-lock.json + npm-audit.json + if-no-files-found: warn + retention-days: 7 + + - name: Enforce high and critical vulnerability threshold + if: always() + run: | + node <<'NODE' + const fs = require("node:fs"); + + const reportPath = "npm-audit.json"; + + if (!fs.existsSync(reportPath)) { + console.error( + "Dependency audit report was not generated.", + ); + process.exit(2); + } + + let report; + + try { + report = JSON.parse( + fs.readFileSync(reportPath, "utf8"), + ); + } catch (error) { + console.error( + "Dependency audit report is invalid JSON.", + ); + console.error(error); + process.exit(2); + } + + if (report.error) { + console.error("npm audit could not complete:"); + console.error( + JSON.stringify(report.error, null, 2), + ); + process.exit(2); + } + + const counts = report.metadata?.vulnerabilities; + + if (!counts) { + console.error( + "Audit report has no vulnerability totals.", + ); + process.exit(2); + } + + const high = counts.high ?? 0; + const critical = counts.critical ?? 0; + + console.log( + `Audit totals: critical=${critical}, ` + + `high=${high}, ` + + `moderate=${counts.moderate ?? 0}, ` + + `low=${counts.low ?? 0}`, + ); + + for (const [name, vulnerability] of Object.entries( + report.vulnerabilities ?? {}, + )) { + if ( + vulnerability.severity === "high" || + vulnerability.severity === "critical" + ) { + console.error( + `${name}: ${vulnerability.severity}; ` + + `range=${vulnerability.range}; ` + + `fixAvailable=${JSON.stringify( + vulnerability.fixAvailable ?? false, + )}`, + ); + } + } + + if (high > 0 || critical > 0) { + console.error( + "High or critical vulnerabilities remain.", + ); + process.exit(1); + } + + console.log( + "Dependency audit passed the high/critical threshold.", + ); + NODE + + sync-lockfile: + name: Synchronize dependency lockfile + runs-on: ubuntu-latest + needs: checks + if: github.event_name == 'push' && github.ref_name == 'JJTGG-patch-2' && needs.checks.result == 'success' + timeout-minutes: 10 + permissions: + contents: write + actions: write + + steps: + - name: Check out the triggering commit + uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 22 + + - name: Check whether manifest and lockfile are in sync + id: lockfile + run: | + node <<'NODE' + const fs = require("node:fs"); + + const manifest = JSON.parse( + fs.readFileSync("package.json", "utf8"), + ); + const lock = JSON.parse( + fs.readFileSync("package-lock.json", "utf8"), + ); + const lockRoot = lock.packages?.[""]; + + if (!lockRoot) { + console.error( + "package-lock.json has no root package entry.", + ); + process.exit(2); + } + + const sections = [ + "dependencies", + "devDependencies", + "optionalDependencies", + ]; + const normalize = (value) => + JSON.stringify( + Object.fromEntries( + Object.entries(value ?? {}).sort( + ([left], [right]) => + left.localeCompare(right), + ), + ), + ); + const mismatches = sections.filter( + (section) => + normalize(manifest[section]) !== + normalize(lockRoot[section]), + ); + const needsRefresh = mismatches.length > 0; + + console.log( + needsRefresh + ? `Lockfile refresh required: ${mismatches.join(", ")}` + : "Manifest and lockfile root dependencies match.", + ); + fs.appendFileSync( + process.env.GITHUB_OUTPUT, + `needs-refresh=${needsRefresh}\n`, + ); + NODE + + - name: Refresh lockfile without running package scripts + if: steps.lockfile.outputs.needs-refresh == 'true' + run: npm install --package-lock-only --ignore-scripts --no-audit --no-fund + + - name: Validate manifest and lockfile synchronization + run: | + node <<'NODE' + const fs = require("node:fs"); + + const manifest = JSON.parse( + fs.readFileSync("package.json", "utf8"), + ); + const lock = JSON.parse( + fs.readFileSync("package-lock.json", "utf8"), + ); + const lockRoot = lock.packages?.[""]; + + if (!lockRoot) { + console.error( + "package-lock.json has no root package entry.", + ); + process.exit(2); + } + + const sections = [ + "dependencies", + "devDependencies", + "optionalDependencies", + ]; + const normalize = (value) => + JSON.stringify( + Object.fromEntries( + Object.entries(value ?? {}).sort( + ([left], [right]) => + left.localeCompare(right), + ), + ), + ); + const mismatches = sections.filter( + (section) => + normalize(manifest[section]) !== + normalize(lockRoot[section]), + ); + + if (mismatches.length > 0) { + console.error( + `Manifest and lockfile still differ: ${mismatches.join(", ")}`, + ); + process.exit(1); + } + + console.log( + "Manifest and lockfile root dependency sections match.", + ); + NODE + + - name: Commit lockfile change when needed + id: commit-lockfile + run: | + set -euo pipefail + + if git diff --quiet -- package-lock.json; then + echo "No package-lock.json change is required." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git add -- package-lock.json + git diff --cached --check + + git fetch --no-tags origin "refs/heads/${GITHUB_REF_NAME}" + remote_sha="$(git rev-parse FETCH_HEAD)" + + if [[ "$remote_sha" != "$GITHUB_SHA" ]]; then + echo "Branch advanced while CI was running; refusing to commit a stale lockfile." + echo "The newer branch head is left untouched." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git -c user.name="github-actions[bot]" \ + -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit -m "chore: sync package lockfile" + + git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Dispatch verification run for the bot commit + if: steps.commit-lockfile.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api \ + --method POST \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/dispatches" \ + -f "ref=${GITHUB_REF_NAME}" diff --git a/app/api/analytics/pageview/route.ts b/app/api/analytics/pageview/route.ts index 03ee06e..865ff22 100644 --- a/app/api/analytics/pageview/route.ts +++ b/app/api/analytics/pageview/route.ts @@ -1,12 +1,14 @@ - import { NextResponse } from "next/server"; import { z } from "zod"; import { createAdminClient } from "@/lib/supabase/admin"; import { createClient } from "@/lib/supabase/server"; +import { readBoundedJsonBody } from "@/lib/security/bounded-json"; export const dynamic = "force-dynamic"; +const MAX_PAGEVIEW_BODY_BYTES = 2048; + const eventSchema = z .object({ pageviewId: z.uuid(), @@ -41,21 +43,19 @@ function response(status = 202, accepted = true) { } export async function POST(request: Request) { - let body: unknown; - - try { - const rawBody = await request.text(); - - if (rawBody.length > 2048) { - return response(413, false); - } + const boundedBody = await readBoundedJsonBody( + request, + MAX_PAGEVIEW_BODY_BYTES, + ); - body = JSON.parse(rawBody); - } catch { - return response(400, false); + if (!boundedBody.ok) { + return response( + boundedBody.reason === "too_large" ? 413 : 400, + false, + ); } - const parsed = eventSchema.safeParse(body); + const parsed = eventSchema.safeParse(boundedBody.value); if (!parsed.success) { return response(400, false); @@ -63,13 +63,14 @@ export async function POST(request: Request) { try { const supabase = await createClient(); + const { data: { user }, error: authError, } = await supabase.auth.getUser(); - // Anonymous page views are valid. A failed session lookup must never - // cause a client-supplied user ID to be trusted. + // Anonymous page views are valid. A failed session lookup must + // never cause a client-supplied user ID to be trusted. const actorId = authError ? null : user?.id ?? null; const rawCountry = request.headers @@ -106,4 +107,4 @@ export async function POST(request: Request) { console.error("Agoré analytics endpoint failed:", error); return response(503, false); } -} +} \ No newline at end of file diff --git a/app/api/onboarding/route.ts b/app/api/onboarding/route.ts index af23c8d..7ebcce5 100644 --- a/app/api/onboarding/route.ts +++ b/app/api/onboarding/route.ts @@ -19,30 +19,6 @@ const onboardingSchema = z.object({ export async function POST(request: Request) { try { - let body: unknown; - - try { - body = await request.json(); - } catch { - return NextResponse.json( - { error: "Invalid request body." }, - { status: 400 }, - ); - } - - const parsed = onboardingSchema.safeParse(body); - - if (!parsed.success) { - return NextResponse.json( - { - error: - parsed.error.issues[0]?.message ?? - "Invalid profile details.", - }, - { status: 400 }, - ); - } - const supabase = await createClient(); const { @@ -102,40 +78,91 @@ export async function POST(request: Request) { ); } - if (existingProfile) { - return NextResponse.json({ - completed: true, - }); - } + if (!existingProfile) { + let body: unknown; - const { error: profileError } = await admin - .from("profiles") - .insert({ - id: authoritativeUser.id, - display_name: parsed.data.displayName, - username: parsed.data.username, - bio: parsed.data.bio || null, - }); - - if (profileError) { - if (profileError.code === "23505") { + try { + body = await request.json(); + } catch { return NextResponse.json( - { error: "That username is already taken." }, - { status: 409 }, + { error: "Invalid request body." }, + { status: 400 }, ); } - console.error( - "Agore profile creation failed:", - profileError, - ); + const parsed = onboardingSchema.safeParse(body); - return NextResponse.json( - { error: "Unable to create your profile." }, - { status: 500 }, - ); + if (!parsed.success) { + return NextResponse.json( + { + error: + parsed.error.issues[0]?.message ?? + "Invalid profile details.", + }, + { status: 400 }, + ); + } + + const { error: profileError } = await admin + .from("profiles") + .insert({ + id: authoritativeUser.id, + display_name: parsed.data.displayName, + username: parsed.data.username, + bio: parsed.data.bio || null, + }); + + if (profileError) { + if (profileError.code !== "23505") { + console.error( + "Agore profile creation failed:", + profileError, + ); + + return NextResponse.json( + { error: "Unable to create your profile." }, + { status: 500 }, + ); + } + + // A concurrent request may have created this user's profile + // after the initial lookup. Distinguish that case from a + // username collision belonging to another account. + const { + data: concurrentProfile, + error: concurrentProfileLookupError, + } = await admin + .from("profiles") + .select("id") + .eq("id", authoritativeUser.id) + .maybeSingle(); + + if (concurrentProfileLookupError) { + console.error( + "Failed to verify profile after creation conflict:", + concurrentProfileLookupError, + ); + + return NextResponse.json( + { error: "Unable to verify your profile." }, + { status: 500 }, + ); + } + + if (!concurrentProfile) { + return NextResponse.json( + { error: "That username is already taken." }, + { status: 409 }, + ); + } + } } + // Always initialize these records, including when the profile + // already exists. This makes retries recover from partial failure. + // + // Only user_id is supplied. Existing preference values are not + // intentionally replaced; missing rows use database defaults. const [ { error: preferencesError }, { error: settingsError }, @@ -167,7 +194,7 @@ export async function POST(request: Request) { return NextResponse.json( { error: - "Profile created, but account preferences could not be initialized.", + "Your profile exists, but account preferences could not be initialized. Please try again.", }, { status: 500 }, ); diff --git a/app/api/public/posts/[postId]/route.ts b/app/api/public/posts/[postId]/route.ts index 4be424c..7896278 100644 --- a/app/api/public/posts/[postId]/route.ts +++ b/app/api/public/posts/[postId]/route.ts @@ -1,278 +1,20 @@ - import { NextResponse } from "next/server"; -import { z } from "zod"; - -import { createAdminClient } from "@/lib/supabase/admin"; export const dynamic = "force-dynamic"; -const postIdSchema = z.uuid(); - const noStoreHeaders = { "Cache-Control": "no-store, max-age=0", + "X-Robots-Tag": "noindex, nofollow", }; -type AccountType = - | "personal" - | "creator" - | "business" - | "organization" - | "institution"; - -type VerificationKind = "agore_official" | "paid" | null; - -type ProfileRow = { - display_name: string; - username: string; - avatar_path: string | null; - account_type: AccountType; - account_status: string; -}; - -type MediaRow = { - id: string; - storage_path: string; - mime_type: string; - size_bytes: number; - width: number | null; - height: number | null; - sort_order: number; - created_at: string; -}; - -type VerificationRow = { - verification_kind: "agore_official" | "paid" | "official"; - expires_at: string | null; - revoked_at: string | null; -}; - -type PostRow = { - id: string; - author_id: string; - content: string; - created_at: string; - updated_at: string; - post_media: MediaRow[] | null; - profiles: ProfileRow | ProfileRow[] | null; -}; - -type PublicMediaRow = MediaRow & { - signed_url: string | null; -}; - -function normalizeProfile( - profile: ProfileRow | ProfileRow[] | null, -): ProfileRow | null { - if (!profile) { - return null; - } - - return Array.isArray(profile) - ? profile[0] ?? null - : profile; -} - -function sortMedia(media: MediaRow[] | null | undefined) { - return [...(media ?? [])].sort( - (a, b) => - a.sort_order - b.sort_order || - new Date(a.created_at).getTime() - - new Date(b.created_at).getTime(), +export async function GET() { + return NextResponse.json( + { + error: "Public post previews are temporarily unavailable.", + }, + { + status: 404, + headers: noStoreHeaders, + }, ); -} - -function getActiveVerificationKind( - grant: VerificationRow | null, -): VerificationKind { - if (!grant || grant.revoked_at !== null) { - return null; - } - - if ( - (grant.verification_kind === "agore_official" || - grant.verification_kind === "official") && - grant.expires_at === null - ) { - return "agore_official"; - } - - if ( - grant.verification_kind === "paid" && - grant.expires_at !== null && - Date.parse(grant.expires_at) > Date.now() - ) { - return "paid"; - } - - return null; -} - -export async function GET( - _: Request, - context: { - params: Promise<{ postId: string }>; - }, -) { - const { postId } = await context.params; - const parsedId = postIdSchema.safeParse(postId); - - if (!parsedId.success) { - return NextResponse.json( - { error: "Invalid post ID." }, - { status: 400, headers: noStoreHeaders }, - ); - } - - try { - const admin = createAdminClient(); - - const { data, error } = await admin - .from("posts") - .select(` - id, - author_id, - content, - created_at, - updated_at, - post_media ( - id, - storage_path, - mime_type, - size_bytes, - width, - height, - sort_order, - created_at - ), - profiles!posts_author_id_fkey ( - display_name, - username, - avatar_path, - account_type, - account_status - ) - `) - .eq("id", parsedId.data) - .is("deleted_at", null) - .maybeSingle(); - - if (error) { - console.error("Failed to load shared Agoré post:", error); - - return NextResponse.json( - { error: "Unable to load this post." }, - { status: 500, headers: noStoreHeaders }, - ); - } - - const post = data as unknown as PostRow | null; - - if (!post) { - return NextResponse.json( - { error: "Post not found." }, - { status: 404, headers: noStoreHeaders }, - ); - } - - const profile = normalizeProfile(post.profiles); - - // Only active accounts can expose posts through public share links. - if (!profile || profile.account_status !== "active") { - return NextResponse.json( - { error: "Post not found." }, - { status: 404, headers: noStoreHeaders }, - ); - } - - const [verificationResult, avatarResult] = - await Promise.all([ - admin - .from("profile_verifications") - .select("verification_kind, expires_at, revoked_at") - .eq("user_id", post.author_id) - .maybeSingle(), - - profile.avatar_path - ? admin.storage - .from("avatars") - .createSignedUrl(profile.avatar_path, 60 * 60) - : Promise.resolve({ data: null, error: null }), - ]); - - if (verificationResult.error) { - console.error( - "Unable to load shared-post verification status:", - verificationResult.error, - ); - } - - const verificationKind = getActiveVerificationKind( - verificationResult.error - ? null - : (verificationResult.data as VerificationRow | null), - ); - - const avatarUrl = - avatarResult.data && !avatarResult.error - ? avatarResult.data.signedUrl - : null; - - // The buckets remain private. These links are temporary and are - // created only for attachments belonging to this visible post. - const media: PublicMediaRow[] = await Promise.all( - sortMedia(post.post_media).map(async (item) => { - if (!item.storage_path.startsWith(`${post.id}/`)) { - return { ...item, signed_url: null }; - } - - const { data: signedData, error: signedError } = - await admin.storage - .from("post-media") - .createSignedUrl(item.storage_path, 60 * 60); - - if (signedError) { - console.error( - "Unable to sign shared-post attachment:", - signedError, - ); - - return { ...item, signed_url: null }; - } - - return { - ...item, - signed_url: signedData.signedUrl, - }; - }), - ); - - return NextResponse.json( - { - post: { - id: post.id, - author_id: post.author_id, - content: post.content, - created_at: post.created_at, - updated_at: post.updated_at, - post_media: media, - profiles: { - display_name: profile.display_name, - username: profile.username, - avatar_path: profile.avatar_path, - avatar_url: avatarUrl, - account_type: profile.account_type, - verification_kind: verificationKind, - }, - }, - }, - { headers: noStoreHeaders }, - ); - } catch (error) { - console.error("Public Agoré post endpoint failed:", error); - - return NextResponse.json( - { error: "Unable to load this post." }, - { status: 500, headers: noStoreHeaders }, - ); - } -} +} \ No newline at end of file diff --git a/app/auth/page.tsx b/app/auth/page.tsx index e99bfd4..a23e4bd 100644 --- a/app/auth/page.tsx +++ b/app/auth/page.tsx @@ -65,6 +65,13 @@ export default function AuthPage() { useEffect(() => { const params = new URLSearchParams(window.location.search); const currentError = params.get("error"); + const requestedMode = params.get("mode"); + + if (requestedMode === "signup") { + setMode("signup"); + } else if (requestedMode === "signin") { + setMode("signin"); + } setCallbackError(currentError); diff --git a/app/page.tsx b/app/page.tsx index 8eef8f5..f7168d2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -73,7 +73,7 @@ export default function Home() {
Join Agoré diff --git a/app/u/[username]/page.tsx b/app/u/[username]/page.tsx index 9434762..d78e538 100644 --- a/app/u/[username]/page.tsx +++ b/app/u/[username]/page.tsx @@ -1,7 +1,6 @@ - import { notFound, redirect } from "next/navigation"; -import { createAdminClient } from "@/lib/supabase/admin"; +import { createClient } from "@/lib/supabase/server"; export const dynamic = "force-dynamic"; @@ -17,9 +16,18 @@ export default async function UsernameProfileRedirect({ notFound(); } - const admin = createAdminClient(); + const supabase = await createClient(); + + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); - const { data: profile, error } = await admin + if (authError || !user) { + redirect("/auth"); + } + + const { data: profile, error } = await supabase .from("profiles") .select("id, account_status") .eq("username", username) @@ -27,9 +35,10 @@ export default async function UsernameProfileRedirect({ if (error) { console.error( - "Unable to resolve Agoré mention profile:", + "Unable to resolve Agore username for authenticated user:", error, ); + notFound(); } @@ -38,4 +47,4 @@ export default async function UsernameProfileRedirect({ } redirect(`/profile/${encodeURIComponent(profile.id)}`); -} +} \ No newline at end of file diff --git a/lib/security/bounded-json.ts b/lib/security/bounded-json.ts new file mode 100644 index 0000000..248a39d --- /dev/null +++ b/lib/security/bounded-json.ts @@ -0,0 +1,109 @@ +export type BoundedJsonBodyResult = + | { + ok: true; + value: unknown; + } + | { + ok: false; + reason: "too_large" | "invalid_json"; + }; + +export async function readBoundedJsonBody( + request: Request, + maxBytes: number, +): Promise { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { + throw new RangeError("maxBytes must be a non-negative safe integer."); + } + + const contentLength = request.headers.get("content-length"); + + if (contentLength !== null) { + if (!/^\d+$/.test(contentLength)) { + return { + ok: false, + reason: "invalid_json", + }; + } + + const declaredBytes = Number(contentLength); + + if ( + !Number.isSafeInteger(declaredBytes) || + declaredBytes > maxBytes + ) { + return { + ok: false, + reason: "too_large", + }; + } + } + + if (!request.body) { + return { + ok: false, + reason: "invalid_json", + }; + } + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + if (value.byteLength > maxBytes - totalBytes) { + try { + await reader.cancel(); + } catch { + // The size limit is already exceeded. + } + + return { + ok: false, + reason: "too_large", + }; + } + + totalBytes += value.byteLength; + chunks.push(value); + } + } catch { + return { + ok: false, + reason: "invalid_json", + }; + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + + try { + const text = new TextDecoder("utf-8", { + fatal: true, + }).decode(bytes); + + return { + ok: true, + value: JSON.parse(text) as unknown, + }; + } catch { + return { + ok: false, + reason: "invalid_json", + }; + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 01f6990..dd300be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,11 @@ "@supabase/supabase-js": "2.117.2", "@vercel/analytics": "^2.0.1", "lucide-react": "1.52.0", - "next": "16.3.8", - "react": "19.3.0", - "react-dom": "19.3.0", + "next": "16.4.3", + "react": "19.3.1", + "react-dom": "19.3.1", "web-push": "3.6.7", - "zod": "4.6.5" + "zod": "4.6.7" }, "devDependencies": { "@tailwindcss/postcss": "4.3.3", @@ -603,15 +603,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", - "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.3.tgz", + "integrity": "sha512-a7tz1QCcWWkwC/rPJcPMJk/UNRu9CFhDd3OHYE5hddbJrs8BqRTalxI6GlKCWEGzRLHN+S/B7a4PAHCtm25aBQ==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", - "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.3.tgz", + "integrity": "sha512-4yq5EXBt+VDtAZwTyfijaFp5D2C5oz3Ikw5a2kiX2K9nw72vrZX2q5UuAlkPtUMO1tQz6JTKpaKWjS7OOefTDQ==", "cpu": [ "arm64" ], @@ -625,9 +625,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", - "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.3.tgz", + "integrity": "sha512-1Rvkn/LUhnEF8WxnRkOrqGhbwXui1bkWFxONKIbN+r7C+9KHDSZ+iUoj4CzymVMWUyZIyx9szcb9+kbKhD500A==", "cpu": [ "x64" ], @@ -641,9 +641,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", - "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.3.tgz", + "integrity": "sha512-dCKpXErGg3abUM6t5JTJV/ottVBTStJB5lRJTbgdUdQaQ+jNHiK56Hsc0YAEg/rinSh42hYG3HWiYmEMdilnxg==", "cpu": [ "arm64" ], @@ -657,9 +657,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", - "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.3.tgz", + "integrity": "sha512-WwYbhWkhYBvBtib1K4W0kL8MW2snBD2A5b3SN4vPpqalZbZs5+bQ1yzAdepVoOqrFfEW3dmREy4Zs1MrWcrFbw==", "cpu": [ "arm64" ], @@ -673,9 +673,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", - "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.3.tgz", + "integrity": "sha512-QymWvfUwDEC5/lpJOsxX5VAbyoR2D13+2Jsjz4be7gT4LPqEgdpkeLIR/HFfCLgKzoMhpJbp6I/JnogyNZHcaA==", "cpu": [ "x64" ], @@ -689,9 +689,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", - "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.3.tgz", + "integrity": "sha512-Vmjd9D7DpA4Vv3cUlr82QWgJD/BQZmns801vtC5VWHg6jCIA4a5vMKpQnfrM2pyCrp2eV0ENdUg4PlN3bAc2Yw==", "cpu": [ "x64" ], @@ -705,9 +705,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", - "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.3.tgz", + "integrity": "sha512-lG1pPdPLk7gTyGb00n5JIMLdBJgjb+UEhSdZvFbhUMg0DyC6z4B7RnpSKF9VCd4imkLObVbXOFA0kFCpw7yFow==", "cpu": [ "arm64" ], @@ -721,9 +721,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", - "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.3.tgz", + "integrity": "sha512-NmQC9RkJdnPXRaMxtkCzobB3teVjqF33oSOnwDfi+XYGWWYpt+xlGK3mtktSKLbdjwaWbJu2OkkMiRFGUUncKg==", "cpu": [ "x64" ], @@ -1072,6 +1072,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", @@ -2084,12 +2150,12 @@ } }, "node_modules/next": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", - "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.3.tgz", + "integrity": "sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA==", "license": "MIT", "dependencies": { - "@next/env": "16.3.8", + "@next/env": "16.4.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -2103,15 +2169,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.8", - "@next/swc-darwin-x64": "16.3.8", - "@next/swc-linux-arm64-gnu": "16.3.8", - "@next/swc-linux-arm64-musl": "16.3.8", - "@next/swc-linux-x64-gnu": "16.3.8", - "@next/swc-linux-x64-musl": "16.3.8", - "@next/swc-win32-arm64-msvc": "16.3.8", - "@next/swc-win32-x64-msvc": "16.3.8", - "sharp": "^0.35.4" + "@next/swc-darwin-arm64": "16.4.3", + "@next/swc-darwin-x64": "16.4.3", + "@next/swc-linux-arm64-gnu": "16.4.3", + "@next/swc-linux-arm64-musl": "16.4.3", + "@next/swc-linux-x64-gnu": "16.4.3", + "@next/swc-linux-x64-musl": "16.4.3", + "@next/swc-win32-arm64-msvc": "16.4.3", + "@next/swc-win32-x64-msvc": "16.4.3", + "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -2200,24 +2266,24 @@ } }, "node_modules/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", - "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", - "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", "license": "MIT", "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.3.0" + "react": "^19.3.1" } }, "node_modules/safe-buffer": { @@ -2436,9 +2502,9 @@ } }, "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index dcf205c..ba7e003 100644 --- a/package.json +++ b/package.json @@ -7,18 +7,18 @@ "build": "next build", "start": "next start", "typecheck": "tsc --noEmit", - "test": "tsc --ignoreConfig --target ES2022 --module Node16 --moduleResolution Node16 --types node --esModuleInterop --strict --skipLibCheck --rootDir . --outDir agore-test-build tests/activity-visibility.test.ts tests/rate-limit.test.ts lib/activity/visibility.ts lib/security/rate-limit.ts && node --test \"agore-test-build/tests/*.test.js\"" + "test": "tsc --ignoreConfig --target ES2022 --module Node16 --moduleResolution Node16 --types node --esModuleInterop --strict --skipLibCheck --rootDir . --outDir agore-test-build tests/activity-visibility.test.ts tests/rate-limit.test.ts tests/public-post-endpoint.test.ts tests/bounded-json.test.ts lib/activity/visibility.ts lib/security/rate-limit.ts lib/security/bounded-json.ts \"app/api/public/posts/[postId]/route.ts\" && node --test agore-test-build/tests/activity-visibility.test.js agore-test-build/tests/rate-limit.test.js agore-test-build/tests/public-post-endpoint.test.js agore-test-build/tests/bounded-json.test.js" }, "dependencies": { "@supabase/ssr": "0.12.7", "@supabase/supabase-js": "2.117.2", "@vercel/analytics": "^2.0.1", "lucide-react": "1.52.0", - "next": "16.3.8", - "react": "19.3.0", - "react-dom": "19.3.0", + "next": "16.4.3", + "react": "19.3.1", + "react-dom": "19.3.1", "web-push": "3.6.7", - "zod": "4.6.5" + "zod": "4.6.7" }, "devDependencies": { "@tailwindcss/postcss": "4.3.3", diff --git a/tests/bounded-json.test.ts b/tests/bounded-json.test.ts new file mode 100644 index 0000000..b56537f --- /dev/null +++ b/tests/bounded-json.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { readBoundedJsonBody } from "../lib/security/bounded-json"; + +const endpoint = "https://agore.test/api/analytics/pageview"; +const maxBytes = 2048; + +test("accepts valid JSON within the byte limit", async () => { + const request = new Request(endpoint, { + method: "POST", + body: JSON.stringify({ path: "/home", device: "mobile" }), + }); + + const result = await readBoundedJsonBody(request, maxBytes); + + assert.equal(result.ok, true); + + if (result.ok) { + assert.deepEqual(result.value, { + path: "/home", + device: "mobile", + }); + } +}); + +test("rejects an oversized declared Content-Length", async () => { + const request = new Request(endpoint, { + method: "POST", + headers: { + "content-length": String(maxBytes + 1), + }, + body: "{}", + }); + + const result = await readBoundedJsonBody(request, maxBytes); + + assert.deepEqual(result, { + ok: false, + reason: "too_large", + }); +}); + +test("rejects an oversized streamed body without Content-Length", async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(maxBytes + 1)); + controller.close(); + }, + }); + + const request = new Request(endpoint, { + method: "POST", + body, + duplex: "half", + } as RequestInit); + + assert.equal(request.headers.get("content-length"), null); + + const result = await readBoundedJsonBody(request, maxBytes); + + assert.deepEqual(result, { + ok: false, + reason: "too_large", + }); +}); + +test("enforces the limit in UTF-8 bytes, not JavaScript string length", async () => { + const request = new Request(endpoint, { + method: "POST", + body: JSON.stringify("é".repeat(1025)), + }); + + const result = await readBoundedJsonBody(request, maxBytes); + + assert.deepEqual(result, { + ok: false, + reason: "too_large", + }); +}); + +test("rejects malformed JSON", async () => { + const request = new Request(endpoint, { + method: "POST", + body: '{"path":', + }); + + const result = await readBoundedJsonBody(request, maxBytes); + + assert.deepEqual(result, { + ok: false, + reason: "invalid_json", + }); +}); + +test("rejects invalid UTF-8", async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([0xc3, 0x28])); + controller.close(); + }, + }); + + const request = new Request(endpoint, { + method: "POST", + body, + duplex: "half", + } as RequestInit); + + const result = await readBoundedJsonBody(request, maxBytes); + + assert.deepEqual(result, { + ok: false, + reason: "invalid_json", + }); +}); \ No newline at end of file diff --git a/tests/public-post-endpoint.test.ts b/tests/public-post-endpoint.test.ts new file mode 100644 index 0000000..b7f656e --- /dev/null +++ b/tests/public-post-endpoint.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { GET } from "../app/api/public/posts/[postId]/route"; + +test("public post endpoint remains closed and returns no post data", async () => { + const response = await GET(); + + assert.equal(response.status, 404); + + assert.equal( + response.headers.get("Cache-Control"), + "no-store, max-age=0", + ); + + assert.equal( + response.headers.get("X-Robots-Tag"), + "noindex, nofollow", + ); + + assert.deepEqual(await response.json(), { + error: "Public post previews are temporarily unavailable.", + }); +}); \ No newline at end of file