Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 deletions app/api/admin/posts/[id]/resend-token/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
import { sendEditTokenEmail } from "@/lib/resend/send";
import { randomBytes, createHash } from "crypto";
import { randomBytes } from "crypto";
import { hashEditToken } from "@/lib/utils/hash";

export async function POST(
req: NextRequest,
Expand All @@ -24,7 +25,7 @@ export async function POST(
}

const token = randomBytes(32).toString("hex");
const tokenHash = createHash("sha256").update(token).digest("hex");
const tokenHash = hashEditToken(token);
const expiresAt = new Date(
Date.now() + 30 * 24 * 60 * 60 * 1000,
).toISOString();
Expand Down
10 changes: 3 additions & 7 deletions app/api/edit/[token]/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { createHash } from "crypto";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
import { redactPii } from "@/lib/utils/pii";

function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
import { hashEditToken } from "@/lib/utils/hash";

interface RouteParams {
params: { token: string };
Expand All @@ -16,7 +12,7 @@ export async function GET(
_req: NextRequest,
{ params }: RouteParams,
): Promise<NextResponse> {
const tokenHash = hashToken(params.token);
const tokenHash = hashEditToken(params.token);
const supabase = createSupabaseAdminClient();

const { data, error } = await supabase
Expand Down Expand Up @@ -58,7 +54,7 @@ export async function PATCH(
req: NextRequest,
{ params }: RouteParams,
): Promise<NextResponse> {
const tokenHash = hashToken(params.token);
const tokenHash = hashEditToken(params.token);
const supabase = createSupabaseAdminClient();

// Verify token resolves to a post
Expand Down
10 changes: 3 additions & 7 deletions app/api/posts/edit/[token]/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { createHash } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
import { submitSchema, screenshotUrlsSchema } from "@/lib/schemas/submit";
import { redactPii } from "@/lib/utils/pii";
import { hashEditToken } from "@/lib/utils/hash";

const editSchema = submitSchema.extend({
screenshotUrls: screenshotUrlsSchema,
Expand All @@ -24,18 +24,14 @@ interface EditablePostRow {
post_tags: Array<{ tags: { slug: string | null } | null }> | null;
}

function getTokenHash(token: string): string {
return createHash("sha256").update(token).digest("hex");
}

interface RouteContext {
params: { token: string };
}

export async function GET(_req: NextRequest, { params }: RouteContext) {
try {
const supabase = createSupabaseAdminClient();
const tokenHash = getTokenHash(params.token);
const tokenHash = hashEditToken(params.token);

const { data, error } = await supabase
.from("posts")
Expand Down Expand Up @@ -115,7 +111,7 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
);
}

const tokenHash = getTokenHash(params.token);
const tokenHash = hashEditToken(params.token);
const supabase = createSupabaseAdminClient();

const { data: existing, error: existingError } = await supabase
Expand Down
2 changes: 2 additions & 0 deletions app/api/posts/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const sendEditTokenEmail = vi.fn();
const hashIp = vi.fn(() => "hashed-ip");
const getClientIp = vi.fn(() => "127.0.0.1");
const hashEditToken = vi.fn(() => "hashed-edit-token");
const consumeSharedRateLimit = vi.fn(async () => ({
allowed: true,
remaining: 2,
Expand All @@ -23,6 +24,7 @@ vi.mock("@/lib/resend/send", () => ({
vi.mock("@/lib/utils/hash", () => ({
hashIp,
getClientIp,
hashEditToken,
}));

vi.mock("@/lib/rate-limit/shared", () => ({
Expand Down
6 changes: 3 additions & 3 deletions app/api/posts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import { submitSchema, screenshotUrlsSchema } from "@/lib/schemas/submit";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
import { sendEditTokenEmail } from "@/lib/resend/send";
import { hashIp, getClientIp } from "@/lib/utils/hash";
import { hashIp, getClientIp, hashEditToken } from "@/lib/utils/hash";
import { redactPii } from "@/lib/utils/pii";
import { randomBytes, createHash } from "crypto";
import { randomBytes } from "crypto";
import { logEvent } from "@/lib/observability/events";
import { consumeSharedRateLimit } from "@/lib/rate-limit/shared";

Expand Down Expand Up @@ -94,7 +94,7 @@ export async function POST(req: NextRequest) {

// Generate edit token — raw token sent to user, hash stored in DB
const rawToken = randomBytes(32).toString("hex");
const tokenHash = createHash("sha256").update(rawToken).digest("hex");
const tokenHash = hashEditToken(rawToken);

// Insert post (status = pending, goes to moderation queue)
const { data: post, error: postErr } = await supabase
Expand Down
8 changes: 2 additions & 6 deletions app/api/status/[token]/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { createHash } from "crypto";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";

function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
import { hashEditToken } from "@/lib/utils/hash";

export async function GET(
_req: NextRequest,
{ params }: { params: { token: string } },
): Promise<NextResponse> {
const tokenHash = hashToken(params.token);
const tokenHash = hashEditToken(params.token);
const supabase = createSupabaseAdminClient();

const { data, error } = await supabase
Expand Down
8 changes: 2 additions & 6 deletions app/edit/[token]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Metadata } from "next";
import { createHash } from "crypto";
import { notFound } from "next/navigation";
import { EditCaseForm } from "@/components/post/EditCaseForm";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
import type { SubmitFormValues } from "@/lib/schemas/submit";
import { hashEditToken } from "@/lib/utils/hash";

export const metadata: Metadata = {
title: "Edit Case — AgentPostmortem",
Expand All @@ -30,13 +30,9 @@ interface EditablePostRow {
post_tags: Array<{ tags: { slug: string | null } | null }> | null;
}

function getTokenHash(token: string): string {
return createHash("sha256").update(token).digest("hex");
}

export default async function EditSubmissionPage({ params }: PageProps) {
const supabase = createSupabaseAdminClient();
const tokenHash = getTokenHash(params.token);
const tokenHash = hashEditToken(params.token);

const [{ data: post }, { data: agents }, { data: tags }] = await Promise.all([
supabase
Expand Down
16 changes: 15 additions & 1 deletion lib/utils/hash.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { getClientIp, hashIp } from "./hash";
import { getClientIp, hashEditToken, hashIp } from "./hash";

describe("hashIp", () => {
const originalEnv = { ...process.env };
Expand Down Expand Up @@ -60,3 +60,17 @@ describe("getClientIp", () => {
expect(getClientIp(headers)).toBe("unknown");
});
});

describe("hashEditToken", () => {
it("is deterministic for the same input", () => {
expect(hashEditToken("fixed-test-token")).toBe(
hashEditToken("fixed-test-token"),
);
});

it("matches a known digest for a fixed input", () => {
expect(hashEditToken("fixed-test-token")).toBe(
"abae2c734c2b0249ef1d413fdf30c332c6875fde570f9bbeef4295966f0b4943",
);
});
});
16 changes: 15 additions & 1 deletion lib/utils/hash.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createHmac } from "crypto";
import { createHash, createHmac } from "crypto";

/**
* Hash an IP address using HMAC-SHA256 with a secret pepper.
Expand Down Expand Up @@ -33,3 +33,17 @@ export function getClientIp(headers: Headers): string {
}
return headers.get("x-real-ip") ?? "unknown";
}

/**
* Hash an edit token with SHA-256 for storage as `posts.edit_token_hash`.
*
* The raw token is sent to the submitter and never stored; only this hash
* lives in the database. Do not change the algorithm here without a
* migration plan, existing rows were written with this exact scheme.
*
* @param token - Raw edit token (hex string from randomBytes)
* @returns Hex-encoded SHA-256 hash
*/
export function hashEditToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
Loading