diff --git a/bun.lock b/bun.lock index 1470361..dddabdf 100644 --- a/bun.lock +++ b/bun.lock @@ -39,6 +39,23 @@ "@types/node": "catalog:types", }, }, + "examples/nextjs-deletion-webhook": { + "name": "dsar-nextjs-deletion-webhook-example", + "dependencies": { + "@dsar/node-sdk": "workspace:*", + "dotenv": "17.4.2", + "next": "^16.2.11", + "react": "^19.0.0", + "react-dom": "^19.0.0", + }, + "devDependencies": { + "@dsar/typescript-config": "workspace:*", + "@types/node": "catalog:types", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "typescript": "catalog:utils", + }, + }, "packages/auth-unkey": { "name": "@dsar/auth-unkey", "version": "0.0.5", @@ -1208,6 +1225,10 @@ "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + + "@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="], + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], @@ -1476,6 +1497,8 @@ "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="], "debounce": ["debounce@2.2.0", "", {}, "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw=="], @@ -1524,6 +1547,8 @@ "dsar-kitchen-sink-example": ["dsar-kitchen-sink-example@workspace:examples/kitchen-sink"], + "dsar-nextjs-deletion-webhook-example": ["dsar-nextjs-deletion-webhook-example@workspace:examples/nextjs-deletion-webhook"], + "dts-resolver": ["dts-resolver@3.0.0", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], diff --git a/examples/nextjs-deletion-webhook/.env.example b/examples/nextjs-deletion-webhook/.env.example new file mode 100644 index 0000000..541b5a8 --- /dev/null +++ b/examples/nextjs-deletion-webhook/.env.example @@ -0,0 +1,5 @@ +# Shared signing secret configured for your DSAR outbound webhook endpoint +DSAR_WEBHOOK_SECRET=whsec_demo_secret_key_12345 + +# Optional path to demo user data store file (defaults to .demo-users.json) +DEMO_DATABASE_PATH=.demo-users.json diff --git a/examples/nextjs-deletion-webhook/.gitignore b/examples/nextjs-deletion-webhook/.gitignore new file mode 100644 index 0000000..85c6c75 --- /dev/null +++ b/examples/nextjs-deletion-webhook/.gitignore @@ -0,0 +1,6 @@ +.next/ +.env +.demo-users.json +*.db +*.db-shm +*.db-wal diff --git a/examples/nextjs-deletion-webhook/README.md b/examples/nextjs-deletion-webhook/README.md new file mode 100644 index 0000000..dda990a --- /dev/null +++ b/examples/nextjs-deletion-webhook/README.md @@ -0,0 +1,63 @@ +# Next.js Deletion Webhook Quickstart + +A production-ready Next.js App Router quickstart demonstrating how to receive outbound DSAR deletion webhooks using `@dsar/node-sdk/webhooks/next`. + +## Quickstart in 5 Steps + +### 1. Clone & Install + +```bash +bun install +``` + +### 2. Configure Environment + +Copy `.env.example` to `.env`: + +```bash +cp .env.example .env +``` + +### 3. Inspect Route Handler + +The Next.js App Router POST route handler lives in [`app/api/webhooks/dsar/route.ts`](app/api/webhooks/dsar/route.ts): + +```typescript +import { createWebhookReceiver } from "@dsar/node-sdk/webhooks"; +import { nextWebhookHandler } from "@dsar/node-sdk/webhooks/next"; +import { deleteDemoUserByEmail } from "../../../../lib/db"; + +const receiver = createWebhookReceiver({ + signingSecret: process.env.DSAR_WEBHOOK_SECRET!, +}); + +receiver.on("request_captured", (event) => { + deleteDemoUserByEmail(event.payload.email as string); +}); + +export const POST = nextWebhookHandler(receiver); +``` + +### 4. Start Dev Server + +```bash +bun run dev +``` + +### 5. Run Smoke Test + +Run the automated end-to-end verification script: + +```bash +bun run smoke +``` + +--- + +## Deploying to Vercel + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Finthhq%2Fdsar%2Ftree%2Fmain%2Fexamples%2Fnextjs-deletion-webhook&env=DSAR_WEBHOOK_SECRET) + +1. Click the button above to clone and deploy to Vercel. +2. Set `DSAR_WEBHOOK_SECRET` in your Vercel Environment Variables. +3. Configure your DSAR Webhook Endpoint URL to point at `https://your-domain.vercel.app/api/webhooks/dsar`. diff --git a/examples/nextjs-deletion-webhook/app/api/webhooks/dsar/route.ts b/examples/nextjs-deletion-webhook/app/api/webhooks/dsar/route.ts new file mode 100644 index 0000000..ec22f74 --- /dev/null +++ b/examples/nextjs-deletion-webhook/app/api/webhooks/dsar/route.ts @@ -0,0 +1,42 @@ +import { createWebhookReceiver } from "@dsar/node-sdk/webhooks"; +import { nextWebhookHandler } from "@dsar/node-sdk/webhooks/next"; + +import { deleteDemoUserByEmail } from "../../../../lib/db"; + +const signingSecret = + process.env.DSAR_WEBHOOK_SECRET ?? "whsec_demo_secret_key_12345"; + +const receiver = createWebhookReceiver({ signingSecret }); + +receiver.on("request_captured", (event) => { + const email = + typeof event.payload.email === "string" + ? event.payload.email + : undefined; + if (email) { + const deleted = deleteDemoUserByEmail(email); + console.log( + `[DSAR Webhook] Processed request_captured event (record_deleted: ${deleted})` + ); + } +}); + +receiver.on("request_fulfilled", (event) => { + const email = + typeof event.payload.email === "string" + ? event.payload.email + : undefined; + if (email) { + const deleted = deleteDemoUserByEmail(email); + console.log( + `[DSAR Webhook] Processed request_fulfilled event (record_deleted: ${deleted})` + ); + } +}); + +/** + * Next.js App Router route handler for DSAR webhooks. + * + * Verifies HMAC signature, extracts subject payload, and executes data erasure. + */ +export const POST = nextWebhookHandler(receiver); diff --git a/examples/nextjs-deletion-webhook/lib/db.ts b/examples/nextjs-deletion-webhook/lib/db.ts new file mode 100644 index 0000000..25050c3 --- /dev/null +++ b/examples/nextjs-deletion-webhook/lib/db.ts @@ -0,0 +1,118 @@ +import fs from "node:fs"; + +/** Demo user record stored in the application database. */ +export interface DemoUser { + readonly id: string; + readonly email: string; + readonly name: string; + readonly status: string; +} + +/** + * Lightweight, zero-dependency demo database manager for quickstarts + * and serverless deployments (Vercel, Node, Bun). + */ +export class DemoDatabase { + private readonly filePath: string; + private users: Map = new Map(); + + constructor( + filePath = process.env.DEMO_DATABASE_PATH ?? ".demo-users.json" + ) { + this.filePath = filePath; + this.load(); + } + + private load(): void { + if (fs.existsSync(this.filePath)) { + try { + const data = fs.readFileSync(this.filePath, "utf8"); + const list: readonly DemoUser[] = JSON.parse(data); + this.users = new Map(list.map((user) => [user.email, user])); + } catch { + this.users = new Map(); + } + } + } + + private save(): void { + try { + const list = Array.from(this.users.values()); + fs.writeFileSync( + this.filePath, + JSON.stringify(list, null, 2), + "utf8" + ); + } catch { + // Ignore filesystem write errors in read-only serverless environments + } + } + + /** Seeds initial user records into the demo database. */ + seed(): readonly DemoUser[] { + const demoUsers: readonly DemoUser[] = [ + { + email: "alex.subject@example.com", + id: "usr_alex_123", + name: "Alex Subject", + status: "active", + }, + { + email: "jordan.user@example.com", + id: "usr_jordan_456", + name: "Jordan User", + status: "active", + }, + ]; + for (const user of demoUsers) { + this.users.set(user.email, user); + } + this.save(); + return demoUsers; + } + + /** Deletes a demo user record by email. */ + deleteByEmail(email: string): boolean { + const existed = this.users.has(email); + if (existed) { + this.users.delete(email); + this.save(); + } + return existed; + } + + /** Looks up a demo user record by email. */ + findByEmail(email: string): DemoUser | undefined { + return this.users.get(email); + } +} + +let dbInstance: DemoDatabase | undefined; + +/** + * Returns a shared demo database instance. + * + * @param dbPath - Optional path to user data store file. + */ +export const getDb = (dbPath?: string): DemoDatabase => { + if (!dbInstance) { + dbInstance = new DemoDatabase(dbPath); + } + return dbInstance; +}; + +/** Seeds demo users. */ +export const seedDemoUsers = (dbPath?: string): readonly DemoUser[] => + getDb(dbPath).seed(); + +/** Deletes demo user by email. */ +export const deleteDemoUserByEmail = ( + email: string, + dbPath?: string +): boolean => getDb(dbPath).deleteByEmail(email); + +/** Finds demo user by email. */ +export const findDemoUserByEmail = ( + email: string, + dbPath?: string +): DemoUser | undefined => getDb(dbPath).findByEmail(email); diff --git a/examples/nextjs-deletion-webhook/package.json b/examples/nextjs-deletion-webhook/package.json new file mode 100644 index 0000000..2379bf3 --- /dev/null +++ b/examples/nextjs-deletion-webhook/package.json @@ -0,0 +1,27 @@ +{ + "name": "dsar-nextjs-deletion-webhook-example", + "private": true, + "description": "Quickstart example demonstrating DSAR deletion webhooks with Next.js App Router and @dsar/node-sdk.", + "type": "module", + "scripts": { + "build": "next build", + "dev": "next dev", + "start": "next start", + "smoke": "bun ./scripts/smoke.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@dsar/node-sdk": "workspace:*", + "dotenv": "17.4.2", + "next": "^16.2.11", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@dsar/typescript-config": "workspace:*", + "@types/node": "catalog:types", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "typescript": "catalog:utils" + } +} diff --git a/examples/nextjs-deletion-webhook/scripts/smoke.ts b/examples/nextjs-deletion-webhook/scripts/smoke.ts new file mode 100644 index 0000000..7e0b84d --- /dev/null +++ b/examples/nextjs-deletion-webhook/scripts/smoke.ts @@ -0,0 +1,87 @@ +import { POST } from "../app/api/webhooks/dsar/route"; +import { findDemoUserByEmail, seedDemoUsers } from "../lib/db"; + +const SIGNING_SECRET = + process.env.DSAR_WEBHOOK_SECRET ?? "whsec_demo_secret_key_12345"; + +const textEncoder = new TextEncoder(); + +const computeHmacHex = async ( + body: string, + secret: string +): Promise => { + const key = await crypto.subtle.importKey( + "raw", + textEncoder.encode(secret), + { hash: "SHA-256", name: "HMAC" }, + false, + ["sign"] + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + textEncoder.encode(body) + ); + return [...new Uint8Array(signature)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +}; + +const runSmokeTest = async (): Promise => { + console.log("--> Initializing demo database with seed users..."); + seedDemoUsers(); + + const targetEmail = "alex.subject@example.com"; + const initialUser = findDemoUserByEmail(targetEmail); + if (!initialUser) { + throw new Error(`Expected seed user ${targetEmail} to exist before deletion.`); + } + console.log(`--> Found active demo user record id=${initialUser.id}`); + + const payload = { + correlationId: "corr_smoke_123", + eventId: "evt_smoke_456", + eventType: "request_captured", + idempotencyKey: "idem_smoke_789", + locale: "en-US", + payload: { email: targetEmail }, + policyVersion: "2026.1", + requestId: "req_smoke_001", + }; + + const rawBody = JSON.stringify(payload); + const signature = await computeHmacHex(rawBody, SIGNING_SECRET); + + console.log("--> Dispatching signed DSAR webhook payload to Next.js POST route..."); + const request = new Request("http://localhost:3000/api/webhooks/dsar", { + body: rawBody, + headers: { + "content-type": "application/json", + "x-dsar-signature": signature, + }, + method: "POST", + }); + + const response = await POST(request); + console.log(`--> Response HTTP status: ${response.status}`); + if (response.status !== 200) { + throw new Error(`Expected HTTP 200 but received ${response.status}`); + } + + const responseBody = (await response.json()) as { ok: boolean }; + if (!responseBody.ok) { + throw new Error("Expected response body { ok: true }"); + } + + const remainingUser = findDemoUserByEmail(targetEmail); + if (remainingUser !== undefined) { + throw new Error(`User ${targetEmail} was not deleted from database.`); + } + + console.log("✔ Success: Demo user was deleted and webhook ACKed with { ok: true }"); +}; + +runSmokeTest().catch((error) => { + console.error("❌ Smoke test failed:", error); + process.exit(1); +}); diff --git a/examples/nextjs-deletion-webhook/tsconfig.json b/examples/nextjs-deletion-webhook/tsconfig.json new file mode 100644 index 0000000..5e7c665 --- /dev/null +++ b/examples/nextjs-deletion-webhook/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "@dsar/typescript-config/node.json", + "compilerOptions": { + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +}