Skip to content
Merged
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
36 changes: 36 additions & 0 deletions app/api/share-links/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { OwnershipMiddleware } from "@/app/api/snippets/ownership.middleware";
import { shareLinkManagementService } from "@/lib/share-link-management.service";

/**
* DELETE /api/share-links/:id
* Immediately invalidates a share link.
*/
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
const walletAddress = await OwnershipMiddleware.extractWalletAddress(req);

const link = await shareLinkManagementService.revokeShareLink(
id,
walletAddress,
);

return NextResponse.json({
success: true,
data: link,
message: "Share link revoked successfully",
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to revoke share link";
const status =
message.includes("not found") ? 404 :
message.includes("already revoked") ? 409 :
500;
console.error("[share-links] DELETE error:", error);
return NextResponse.json({ success: false, error: message }, { status });
}
}
30 changes: 30 additions & 0 deletions app/api/share-links/[id]/validate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { shareLinkManagementService } from "@/lib/share-link-management.service";

/**
* GET /api/share-links/:token/validate
* Checks whether a share link is active and valid.
* Returns metadata only β€” never snippet content.
*/
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id: token } = await params;
const result = await shareLinkManagementService.validateShareLink(token);

return NextResponse.json({
success: true,
data: {
valid: result.valid,
reason: result.reason ?? null,
link: result.link ?? null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to validate share link";
console.error("[share-links] VALIDATE error:", error);
return NextResponse.json({ success: false, error: message }, { status: 500 });
}
}
81 changes: 81 additions & 0 deletions app/api/share-links/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import { OwnershipMiddleware } from "@/app/api/snippets/ownership.middleware";
import {
shareLinkManagementService,
ShareLinkVisibility,
} from "@/lib/share-link-management.service";

function jsonError(message: string, status: number) {
return NextResponse.json(
{ success: false, error: message },
{ status },
);
}

/**
* POST /api/share-links
* Body: { snippetId, visibility?, expiresAt? }
*/
export async function POST(req: NextRequest) {
try {
const walletAddress = await OwnershipMiddleware.extractWalletAddress(req);
const body = await req.json().catch(() => ({}));
const snippetId = typeof body.snippetId === "string" ? body.snippetId : "";
const visibility = body.visibility as ShareLinkVisibility | undefined;
const expiresAt = body.expiresAt ?? null;

if (!snippetId) {
return jsonError("snippetId is required", 400);
}

if (visibility && visibility !== "read-only" && visibility !== "read-write") {
return jsonError("visibility must be 'read-only' or 'read-write'", 400);
}

const link = await shareLinkManagementService.createShareLink({
snippetId,
visibility,
expiresAt,
createdBy: walletAddress,
});

return NextResponse.json(
{
success: true,
data: link,
},
{ status: 201 },
);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create share link";
const status = message.includes("required") || message.includes("expiresAt") ? 400 : 500;
console.error("[share-links] POST error:", error);
return jsonError(message, status);
}
}

/**
* GET /api/share-links?snippetId=...
* Lists active (non-expired, non-revoked) links for a snippet.
*/
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const snippetId = searchParams.get("snippetId") || "";

if (!snippetId) {
return jsonError("snippetId query parameter is required", 400);
}

const links = await shareLinkManagementService.listActiveShareLinks(snippetId);
return NextResponse.json({
success: true,
data: links,
count: links.length,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to list share links";
console.error("[share-links] GET error:", error);
return jsonError(message, 500);
}
}
166 changes: 166 additions & 0 deletions lib/share-link-management.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import {
InMemoryShareLinkStore,
ShareLinkManagementService,
} from "./share-link-management.service";

jest.mock("@/lib/audit", () => ({
logEvent: jest.fn().mockResolvedValue(undefined),
}));

describe("ShareLinkManagementService", () => {
let store: InMemoryShareLinkStore;
let service: ShareLinkManagementService;

beforeEach(() => {
store = new InMemoryShareLinkStore();
service = new ShareLinkManagementService(store);
});

describe("generateSecureToken", () => {
it("creates unique non-guessable 64-char hex tokens", () => {
const a = service.generateSecureToken();
const b = service.generateSecureToken();
expect(a).toMatch(/^[a-f0-9]{64}$/);
expect(b).toMatch(/^[a-f0-9]{64}$/);
expect(a).not.toBe(b);
});
});

describe("createShareLink", () => {
it("requires snippetId", async () => {
await expect(service.createShareLink({ snippetId: "" })).rejects.toThrow(
"snippetId is required",
);
});

it("creates a secure link with optional expiry and defaults to read-only", async () => {
const expiresAt = new Date(Date.now() + 86_400_000).toISOString();
const link = await service.createShareLink({
snippetId: "abc123",
visibility: "read-only",
expiresAt,
createdBy: "GTEST",
});

expect(link.snippetId).toBe("abc123");
expect(link.visibility).toBe("read-only");
expect(link.status).toBe("active");
expect(link.token).toMatch(/^[a-f0-9]{64}$/);
expect(link.expiresAt).toBe(expiresAt);
expect(link.shareUrl).toContain(link.token);
// Never expose snippet content fields
expect(link as any).not.toHaveProperty("code");
expect(link as any).not.toHaveProperty("content");
});

it("rejects past expiration timestamps", async () => {
await expect(
service.createShareLink({
snippetId: "abc123",
expiresAt: new Date(Date.now() - 1000).toISOString(),
}),
).rejects.toThrow("expiresAt must be in the future");
});

it("records a creation audit event", async () => {
const link = await service.createShareLink({ snippetId: "s1", createdBy: "G1" });
const audit = await service.getAuditLog(link.id);
expect(audit).toHaveLength(1);
expect(audit[0].action).toBe("created");
expect(audit[0].actor).toBe("G1");
expect(audit[0].tokenPrefix).toBe(link.token.slice(0, 8));
});
});

describe("listActiveShareLinks", () => {
it("lists only active links and excludes expired/revoked", async () => {
const active = await service.createShareLink({
snippetId: "snip",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
});
const expired = await service.createShareLink({
snippetId: "snip",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
});
// Force-expire by rewriting store record
const expiredRecord = await store.findById(expired.id);
await store.upsert({
...expiredRecord!,
expiresAt: new Date(Date.now() - 1000).toISOString(),
});

const revoked = await service.createShareLink({ snippetId: "snip" });
await service.revokeShareLink(revoked.id, "G1");

const other = await service.createShareLink({ snippetId: "other" });

const listed = await service.listActiveShareLinks("snip");
expect(listed.map((l) => l.id)).toEqual([active.id]);
expect(listed.every((l) => l.status === "active")).toBe(true);
expect(listed.find((l) => l.id === other.id)).toBeUndefined();
});
});

describe("revokeShareLink", () => {
it("revokes immediately so validation fails", async () => {
const link = await service.createShareLink({ snippetId: "snip" });
const before = await service.validateShareLink(link.token);
expect(before.valid).toBe(true);

const revoked = await service.revokeShareLink(link.id, "Gowner");
expect(revoked.status).toBe("revoked");

const after = await service.validateShareLink(link.token);
expect(after.valid).toBe(false);
expect(after.reason).toBe("revoked");
});

it("throws when link is missing", async () => {
await expect(service.revokeShareLink("missing")).rejects.toThrow(
"Share link not found",
);
});
});

describe("validateShareLink", () => {
it("returns valid metadata without snippet content", async () => {
const link = await service.createShareLink({
snippetId: "abc123",
visibility: "read-write",
});
const result = await service.validateShareLink(link.token);
expect(result.valid).toBe(true);
expect(result.link?.snippetId).toBe("abc123");
expect(result.link?.visibility).toBe("read-write");
expect(result.link as any).not.toHaveProperty("code");
});

it("enforces expiration automatically", async () => {
const link = await service.createShareLink({
snippetId: "abc123",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
});
const record = await store.findById(link.id);
await store.upsert({
...record!,
expiresAt: new Date(Date.now() - 1).toISOString(),
});

const result = await service.validateShareLink(link.token);
expect(result.valid).toBe(false);
expect(result.reason).toBe("expired");
});

it("returns not_found for unknown tokens", async () => {
const result = await service.validateShareLink("deadbeef".repeat(8));
expect(result).toEqual({ valid: false, reason: "not_found" });
});

it("records validation audit events", async () => {
const link = await service.createShareLink({ snippetId: "s1" });
await service.validateShareLink(link.token);
const audit = await service.getAuditLog(link.id);
expect(audit.some((a) => a.action === "validated")).toBe(true);
});
});
});
Loading
Loading