From 37058dca099ef90c7b4106f36f902a19d1976969 Mon Sep 17 00:00:00 2001 From: ANARKY Date: Thu, 24 Sep 2026 12:35:06 +0100 Subject: [PATCH] feat(share-links): add Share Link Management Service Implements secure share-link generation, listing, revocation, validation, and audit logging for Closes #191. --- app/api/share-links/[id]/route.ts | 36 +++ app/api/share-links/[id]/validate/route.ts | 30 ++ app/api/share-links/route.ts | 81 ++++++ lib/share-link-management.service.test.ts | 166 +++++++++++ lib/share-link-management.service.ts | 313 +++++++++++++++++++++ 5 files changed, 626 insertions(+) create mode 100644 app/api/share-links/[id]/route.ts create mode 100644 app/api/share-links/[id]/validate/route.ts create mode 100644 app/api/share-links/route.ts create mode 100644 lib/share-link-management.service.test.ts create mode 100644 lib/share-link-management.service.ts diff --git a/app/api/share-links/[id]/route.ts b/app/api/share-links/[id]/route.ts new file mode 100644 index 0000000..b47c7e9 --- /dev/null +++ b/app/api/share-links/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/share-links/[id]/validate/route.ts b/app/api/share-links/[id]/validate/route.ts new file mode 100644 index 0000000..92ef45e --- /dev/null +++ b/app/api/share-links/[id]/validate/route.ts @@ -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 }); + } +} diff --git a/app/api/share-links/route.ts b/app/api/share-links/route.ts new file mode 100644 index 0000000..40b4065 --- /dev/null +++ b/app/api/share-links/route.ts @@ -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); + } +} diff --git a/lib/share-link-management.service.test.ts b/lib/share-link-management.service.test.ts new file mode 100644 index 0000000..71a38cf --- /dev/null +++ b/lib/share-link-management.service.test.ts @@ -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); + }); + }); +}); diff --git a/lib/share-link-management.service.ts b/lib/share-link-management.service.ts new file mode 100644 index 0000000..26c8e6b --- /dev/null +++ b/lib/share-link-management.service.ts @@ -0,0 +1,313 @@ +import crypto from "crypto"; +import { logEvent } from "@/lib/audit"; + +export type ShareLinkVisibility = "read-only" | "read-write"; + +export type ShareLinkStatus = "active" | "expired" | "revoked"; + +export type ShareLinkAuditAction = "created" | "revoked" | "validated"; + +export interface ShareLinkRecord { + id: string; + snippetId: string; + token: string; + visibility: ShareLinkVisibility; + expiresAt: string | null; + createdAt: string; + createdBy: string | null; + revokedAt: string | null; + revokedBy: string | null; +} + +/** Public metadata only — never includes snippet content. */ +export interface ShareLinkMetadata { + id: string; + snippetId: string; + token: string; + shareUrl: string; + visibility: ShareLinkVisibility; + expiresAt: string | null; + createdAt: string; + status: ShareLinkStatus; +} + +export interface CreateShareLinkInput { + snippetId: string; + visibility?: ShareLinkVisibility; + expiresAt?: string | Date | null; + createdBy?: string | null; +} + +export interface ValidateShareLinkResult { + valid: boolean; + reason?: "not_found" | "revoked" | "expired"; + link?: ShareLinkMetadata; +} + +export interface ShareLinkAuditEntry { + id: string; + linkId: string; + snippetId: string; + action: ShareLinkAuditAction; + actor: string | null; + tokenPrefix: string; + at: string; + details?: Record; +} + +export interface ShareLinkStore { + upsert(link: ShareLinkRecord): Promise; + findById(id: string): Promise; + findByToken(token: string): Promise; + findBySnippetId(snippetId: string): Promise; + appendAudit(entry: ShareLinkAuditEntry): Promise; + listAudit(linkId?: string): Promise; +} + +/** In-memory store used by default (tests + local) and as DB fallback. */ +export class InMemoryShareLinkStore implements ShareLinkStore { + private links = new Map(); + private byToken = new Map(); + private audit: ShareLinkAuditEntry[] = []; + + async upsert(link: ShareLinkRecord): Promise { + this.links.set(link.id, { ...link }); + this.byToken.set(link.token, link.id); + return { ...link }; + } + + async findById(id: string): Promise { + const link = this.links.get(id); + return link ? { ...link } : null; + } + + async findByToken(token: string): Promise { + const id = this.byToken.get(token); + if (!id) return null; + return this.findById(id); + } + + async findBySnippetId(snippetId: string): Promise { + return [...this.links.values()] + .filter((l) => l.snippetId === snippetId) + .map((l) => ({ ...l })); + } + + async appendAudit(entry: ShareLinkAuditEntry): Promise { + this.audit.push({ ...entry, details: entry.details ? { ...entry.details } : undefined }); + } + + async listAudit(linkId?: string): Promise { + const rows = linkId ? this.audit.filter((a) => a.linkId === linkId) : this.audit; + return rows.map((a) => ({ ...a, details: a.details ? { ...a.details } : undefined })); + } + + clear(): void { + this.links.clear(); + this.byToken.clear(); + this.audit = []; + } +} + +const defaultStore = new InMemoryShareLinkStore(); + +function toIso(value: string | Date | null | undefined): string | null { + if (value == null || value === "") return null; + const d = value instanceof Date ? value : new Date(value); + if (Number.isNaN(d.getTime())) { + throw new Error("Invalid expiresAt value"); + } + return d.toISOString(); +} + +function computeStatus(link: ShareLinkRecord, now = new Date()): ShareLinkStatus { + if (link.revokedAt) return "revoked"; + if (link.expiresAt && new Date(link.expiresAt).getTime() <= now.getTime()) { + return "expired"; + } + return "active"; +} + +function buildShareUrl(token: string): string { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; + return `${baseUrl}/api/share-links/${token}/validate`; +} + +function toMetadata(link: ShareLinkRecord, now = new Date()): ShareLinkMetadata { + return { + id: link.id, + snippetId: link.snippetId, + token: link.token, + shareUrl: buildShareUrl(link.token), + visibility: link.visibility, + expiresAt: link.expiresAt, + createdAt: link.createdAt, + status: computeStatus(link, now), + }; +} + +export class ShareLinkManagementService { + constructor(private store: ShareLinkStore = defaultStore) {} + + /** Cryptographically strong, non-guessable token (256-bit hex). */ + generateSecureToken(): string { + return crypto.randomBytes(32).toString("hex"); + } + + async createShareLink(input: CreateShareLinkInput): Promise { + const snippetId = (input.snippetId || "").trim(); + if (!snippetId) { + throw new Error("snippetId is required"); + } + + const visibility: ShareLinkVisibility = + input.visibility === "read-write" ? "read-write" : "read-only"; + + const expiresAt = toIso(input.expiresAt ?? null); + if (expiresAt && new Date(expiresAt).getTime() <= Date.now()) { + throw new Error("expiresAt must be in the future"); + } + + const now = new Date().toISOString(); + const token = this.generateSecureToken(); + const record: ShareLinkRecord = { + id: crypto.randomUUID(), + snippetId, + token, + visibility, + expiresAt, + createdAt: now, + createdBy: input.createdBy ?? null, + revokedAt: null, + revokedBy: null, + }; + + await this.store.upsert(record); + await this.recordAudit(record, "created", input.createdBy ?? null, { + visibility, + expiresAt, + }); + + // Best-effort durable audit (DB); never expose snippet content. + void logEvent( + "share_link.created", + input.createdBy || "anonymous", + snippetId, + JSON.stringify({ linkId: record.id, tokenPrefix: token.slice(0, 8), visibility }), + ); + + return toMetadata(record); + } + + /** Returns only active (non-revoked, non-expired) links for a snippet. */ + async listActiveShareLinks(snippetId: string): Promise { + const id = (snippetId || "").trim(); + if (!id) { + throw new Error("snippetId is required"); + } + const now = new Date(); + const links = await this.store.findBySnippetId(id); + return links + .filter((l) => computeStatus(l, now) === "active") + .map((l) => toMetadata(l, now)) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + } + + async revokeShareLink( + linkId: string, + revokedBy: string | null = null, + ): Promise { + const id = (linkId || "").trim(); + if (!id) { + throw new Error("link id is required"); + } + + const existing = await this.store.findById(id); + if (!existing) { + throw new Error("Share link not found"); + } + if (existing.revokedAt) { + throw new Error("Share link already revoked"); + } + + const updated: ShareLinkRecord = { + ...existing, + revokedAt: new Date().toISOString(), + revokedBy, + }; + await this.store.upsert(updated); + await this.recordAudit(updated, "revoked", revokedBy, {}); + + void logEvent( + "share_link.revoked", + revokedBy || "anonymous", + updated.snippetId, + JSON.stringify({ linkId: updated.id, tokenPrefix: updated.token.slice(0, 8) }), + ); + + return toMetadata(updated); + } + + async validateShareLink(token: string): Promise { + const t = (token || "").trim(); + if (!t) { + return { valid: false, reason: "not_found" }; + } + + const existing = await this.store.findByToken(t); + if (!existing) { + return { valid: false, reason: "not_found" }; + } + + const now = new Date(); + const status = computeStatus(existing, now); + const meta = toMetadata(existing, now); + + await this.recordAudit(existing, "validated", null, { status, valid: status === "active" }); + + void logEvent( + "share_link.validated", + "system", + existing.snippetId, + JSON.stringify({ + linkId: existing.id, + tokenPrefix: existing.token.slice(0, 8), + status, + valid: status === "active", + }), + ); + + if (status === "revoked") { + return { valid: false, reason: "revoked", link: meta }; + } + if (status === "expired") { + return { valid: false, reason: "expired", link: meta }; + } + return { valid: true, link: meta }; + } + + async getAuditLog(linkId?: string): Promise { + return this.store.listAudit(linkId); + } + + private async recordAudit( + link: ShareLinkRecord, + action: ShareLinkAuditAction, + actor: string | null, + details: Record, + ): Promise { + await this.store.appendAudit({ + id: crypto.randomUUID(), + linkId: link.id, + snippetId: link.snippetId, + action, + actor, + tokenPrefix: link.token.slice(0, 8), + at: new Date().toISOString(), + details, + }); + } +} + +/** Shared singleton for API routes. */ +export const shareLinkManagementService = new ShareLinkManagementService();