@zeroclickai/paywrap-adapter-fastify
v0.0.18
Published
Fastify adapter for @zeroclickai/paywrap: route-level MPP gates, challenge helpers, and a preconfigured paid API app factory.
Readme
@zeroclickai/paywrap-adapter-fastify
Fastify adapter for @zeroclickai/paywrap. Collapses the 10-line MPP auth dance into a single mppGated preHandler.
Install
pnpm add @zeroclickai/paywrap @zeroclickai/paywrap-adapter-fastifyfastify@^5 is a peer dependency.
Use
import Fastify from "fastify";
import { createPaywrapMpp } from "@zeroclickai/paywrap/mpp";
import { createFastifyApp } from "@zeroclickai/paywrap-adapter-fastify";
// Charge-intent: the buyer signs and pays gas, so the seller never holds a
// private key. Pass `walletPrivateKey` instead only for session-intent or
// `feePayer: true` charge.
const mpp = createPaywrapMpp({
walletAddress: process.env.WALLET_ADDRESS as `0x${string}`,
mppSecretKey: process.env.MPP_SECRET_KEY!,
publicBaseUrl: process.env.PUBLIC_BASE_URL!,
tempoRpcUrl: "https://rpc.tempo.xyz",
});
const app = await createFastifyApp({
mppx: mpp.mppx,
mppxChannelStore: mpp.channelStore,
walletAddress: mpp.walletAddress, // present in both keyed and address-only modes
});
app.post("/generate", {
preHandler: app.mppGated({ scope: "gen:1", amount: 50_000n, intent: "charge" }),
}, async (req) => {
const payer = req.payer; // Hex, verified
const verified = req.verifiedCredential; // VerifiedCredential (branded)
return { result: await yourLogic(req.body, payer) };
});
await app.listen({ port: 3000 });Default intent matters. When amount is omitted mppGated defaults to "proof"; when amount > 0 it defaults to "session". Always pass intent: "charge" explicitly for atomic single-shot pricing — there is no implicit charge default.
Fire-and-forget after reply.send()? Fastify's pooled buffers can be recycled before your background work runs. Always wrap the buffer with defensiveBufferCopy (see Gotchas).
API
createFastifyApp(ctx)— preconfigured Fastify instance with Zod type provider, ctx decoration,mppGateddecorator.mppGated({ scope, amount?, intent?, meta?, preCheck? })— preHandler factory; consultsapp.ctx.mppx. Options:intent:"session" | "charge" | "proof". Default:"proof"whenamountomitted,"session"whenamount> 0.preCheck(ctx)— async hook that runs AFTER credential parse but BEFORE verify/settle. Return{ok: true}to proceed,{ok: false, status, body}to short-circuit without charging, or{ok: "already_done", payer, verifiedCredential}to skip verify on idempotent retries.
sendSessionChallenge(app, reply, opts)/sendChargeChallenge(app, reply, opts)/sendProofChallenge(app, reply, scope, detail, meta?)— manual 402 responders.extractCredential(headerValue)— re-exported from@zeroclickai/paywrap/auth.defensiveBufferCopy(source)— Node-only helper that copies a Fastify-managed Buffer into a fresh, pool-free allocation. Use this whenever you fire-and-forget after the reply (see Gotchas).
Gotchas
Pooled Buffers + setImmediate after reply
Fastify's content-type parsers (@fastify/multipart, addContentTypeParser({parseAs: "buffer"})) return Buffers backed by a shared pool. A 200-byte upload typically lives at byteOffset=520 of an 8192-byte pool. If your route does:
reply.status(202).send({ ok: true });
// ❌ DANGER: zipBytes is a pool view; the next request can recycle the slot
setImmediate(() => upstream.upload(req.body));Fastify can release the request and recycle that pool slot before setImmediate fires. The fire-and-forget then ships the wrong bytes — observed live in the netlify integration: Netlify reported a "ready" deploy with an empty file_tree because the bytes it received didn't match the buyer's zip.
Fix:
import { defensiveBufferCopy } from "@zeroclickai/paywrap-adapter-fastify";
reply.status(202).send({ ok: true });
const safeCopy = defensiveBufferCopy(req.body as Buffer);
setImmediate(() => upstream.upload(safeCopy)); // ✅ pool-freeBuffer.from(buf) and Uint8Array.from(buf) both round-trip through the pool for small allocations and don't fix this. defensiveBufferCopy uses Buffer.allocUnsafeSlow which bypasses the pool.
Design
Kit returns {status, headers, body} descriptors. This adapter maps those onto Fastify's reply.status().header().send(). Same pattern is used by @zeroclickai/paywrap-adapter-hono — see the repo for a Hono-shaped version.
License
MIT.
