@srcabc/notabot-next
v0.3.1
Published
Drop-in Notabot protection for Next.js App Router (CAPTCHA + durable passkey step-up)
Readme
@srcabc/notabot-next
Official Next.js App Router integration for Notabot protected actions.
Install
npm install @srcabc/notabot-next @srcabc/notabot-serverClient Gate
"use client"
import { useRouter } from "next/navigation"
import { NotabotGate } from "@srcabc/notabot-next/client"
export function ArticleGate() {
const router = useRouter()
return (
<NotabotGate
siteKey={process.env.NEXT_PUBLIC_NOTABOT_SITE_KEY!}
apiBase={process.env.NEXT_PUBLIC_NOTABOT_API_BASE}
action="article_unlock"
unlockUrl="/api/notabot/unlock"
subjectTokenUrl="/api/notabot/subject-token"
onVerified={() => router.refresh()}
/>
)
}NotabotGate loads https://<notabot-origin>/widget/v2/loader.js, mounts the widget, listens for notabot:verified, and sends only proof_token plus optional correlation_id to your unlock route. It does not trust browser state as final authorization. Omit subjectTokenUrl for CAPTCHA-only protection.
Unlock Route
import { createNotabotUnlockRoute } from "@srcabc/notabot-next/server"
export const POST = createNotabotUnlockRoute({
siteKey: process.env.NOTABOT_SITE_KEY!,
signingSecret: process.env.NOTABOT_SIGNING_SECRET!,
apiBase: process.env.NOTABOT_API_BASE ?? "https://notabot.srcabc.com/api/v1",
unlockCookieSecret: process.env.NOTABOT_UNLOCK_COOKIE_SECRET!,
cookieName: "article_unlock",
action: "article_unlock",
scope: "example.com/article",
cookiePath: "/article",
cookieTtlSeconds: 3600,
})The route validates the one-use proof server-to-server, sets a signed HttpOnly unlock cookie on allow, and fails closed on every error.
For partner apps that need site policy checks or a stable response contract, keep the orchestration in the package and configure hooks instead of reimplementing validation:
import { NextResponse } from "next/server"
import { createNotabotUnlockRoute } from "@srcabc/notabot-next/server"
export const POST = createNotabotUnlockRoute({
siteKey: process.env.NOTABOT_SITE_KEY,
signingSecret: process.env.NOTABOT_SIGNING_SECRET,
apiBase: process.env.NOTABOT_API_BASE ?? "https://notabot.srcabc.com/api/v1",
unlockCookieSecret: process.env.NOTABOT_UNLOCK_COOKIE_SECRET,
cookieName: "article_unlock",
action: "article_unlock",
scope: "example.com/article",
cookiePath: "/article",
beforeValidate: async (request) => {
if (request.headers.get("host") !== "example.com") {
return NextResponse.json({ error: "not_found" }, { status: 404 })
}
},
mapError: (failure) => ({
status: failure.reason === "timeout" ? 504 : 403,
code: failure.reason === "timeout" ? "captcha_validation_timeout" : failure.reason,
message: "Verification failed.",
}),
onSuccess: ({ result }) => ({
unlocked: true,
correlationId: result.correlationId ?? null,
expiresAt: result.expiresAt ?? null,
}),
})beforeValidate runs before body parsing and token validation, mapError
shapes validation/configuration failures, and onSuccess shapes the JSON body
without taking over cookie creation.
Page Guard
import { redirect } from "next/navigation"
import { verifyNotabotUnlockCookie } from "@srcabc/notabot-next/server"
export default async function ProtectedPage() {
const unlocked = await verifyNotabotUnlockCookie({
cookieName: "article_unlock",
scope: "example.com/article",
secret: process.env.NOTABOT_UNLOCK_COOKIE_SECRET!,
})
if (!unlocked) redirect("/article/locked")
return <Article />
}The protected content should be rendered only after the server-side guard accepts the signed unlock cookie.
deriveRequestOrigin(request) is exported from @srcabc/notabot-next/server
for apps that need the same origin resolution in their own policy wrappers.
Subject-Token Route (durable passkey step-up)
To enable durable passkey step-up, add a same-origin subject-token route. The widget calls it to obtain a server-minted identity token bound to your site, the widget session, and the request origin.
import { createNotabotSubjectTokenRoute } from "@srcabc/notabot-next/server"
export const POST = createNotabotSubjectTokenRoute({
siteKey: process.env.NOTABOT_SITE_KEY!,
signingSecret: process.env.NOTABOT_SIGNING_SECRET!,
subjectCookieName: "notabot_subject",
allowedScopes: ["challenge", "verify", "register"],
defaultScopes: ["challenge", "verify"],
})- The subject identity is server-authoritative: it lives in a long-lived
HttpOnlyfirst-party cookie and is never taken from the request body. Don't send your ownsubject_idfrom the client. requested_scopesare allowlisted; unknown scopes are rejected.registerenables passkey enrolment;manage_credentialsis not granted by default.- The endpoint must be same-origin with the page (the
NotabotGatesubjectTokenUrlis resolved to an absolute same-origin URL and a cross-origin URL is rejected). - It fails closed (
503) without a signing secret and never logs raw tokens.
Passkey policy
Passkey mode is owned by the Notabot developer portal per-URL/action policy (disabled | optional | step_up | required). Do not set it from the frontend in production. The optional passkeyMode prop on NotabotGate is an explicit override for demos/local development only.
apiBase
You may pass either form to apiBase; the package normalizes internally:
https://notabot.srcabc.com
https://notabot.srcabc.com/api/v1The widget uses the /api/v1 form; NotabotValidator uses the host-only form (its paths already include /api/v1). Mixing them by hand causes /api/v1/api/v1/...; the helpers widgetApiBase/validatorApiBase (exported) prevent that.
Security notes
- Server mints identity and signs contracts; the client only requests verification.
- Never expose
NOTABOT_SIGNING_SECRET/NOTABOT_UNLOCK_COOKIE_SECRETto the browser. - All cryptography lives in
@srcabc/notabot-server; this package never reimplements it. - The unlock and subject-token routes fail closed on every error.
Events
NotabotGate reacts to the widget events: notabot:verified (success), notabot:failed, notabot:error, notabot:expired (challenge expired — actionable "solve again", no auto-retry). Passkey notabot:passkey:fallback / passkey:* events are informational and never treated as fatal (the CAPTCHA fallback still proceeds).
