npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@wraps.dev/better-auth

v0.2.0

Published

Better Auth plugin for Wraps. Sync signups to contacts, trigger workflows, and send verification, password reset, magic link, OTP, and invitation emails through your own AWS SES account.

Readme

@wraps.dev/better-auth

Better Auth plugin for Wraps.

Two things, both opt-in:

  1. Sync — new signups become Wraps contacts and fire a user.signed_up event, so your welcome sequences and onboarding workflows run.
  2. Send — drop-in senders for the transactional email Better Auth otherwise makes you wire by hand: verification, password reset, magic link, OTP, and organisation invites — delivered through your own AWS SES account.

You can use either half on its own. The email half needs no Wraps account at all.

Install

npm install @wraps.dev/better-auth
# or
pnpm add @wraps.dev/better-auth

@wraps.dev/email is an optional peer dependency, only needed for the email half:

pnpm add @wraps.dev/email

Quick start

// auth.ts
import { betterAuth } from 'better-auth';
import { wraps } from '@wraps.dev/better-auth';

export const auth = betterAuth({
  emailAndPassword: { enabled: true },
  plugins: [
    wraps({
      // Sync half — omit to disable
      apiKey: process.env.WRAPS_API_KEY,

      // Email half — omit to disable
      email: {
        from: 'Acme <[email protected]>',
        appName: 'Acme',
        brand: {
          logoUrl: 'https://acme.com/logo.png',
          primaryColor: '#4f46e5',
          supportEmail: '[email protected]',
        },
      },
    }),
  ],
});

That's it. New users are synced, and sendVerificationEmail, sendResetPassword, and onPasswordReset are wired for you.

Client (optional)

import { createAuthClient } from 'better-auth/client';
import { wrapsClient } from '@wraps.dev/better-auth/client';

export const authClient = createAuthClient({ plugins: [wrapsClient()] });

Type inference only. Everything happens server-side and your API key never reaches the browser.

Auth emails

The plugin fills in the senders it can reach. For the ones that belong to other plugins, build them once and pass them in:

import { wrapsAuthEmails } from '@wraps.dev/better-auth';
import { emailOTP, magicLink, organization } from 'better-auth/plugins';

const emails = wrapsAuthEmails({
  from: 'Acme <[email protected]>',
  appName: 'Acme',
  appUrl: 'https://app.acme.com', // used to build the invitation link
});

betterAuth({
  plugins: [
    magicLink({ sendMagicLink: emails.magicLink }),
    emailOTP({ sendVerificationOTP: emails.otp }),
    organization({ sendInvitationEmail: emails.invitation }),
  ],
});

Available senders: verification, resetPassword, passwordChanged, magicLink, otp, invitation.

The plugin never overrides your config

Better Auth merges plugin options under your own, so anything you define wins. If you already have a sendVerificationEmail, the plugin leaves it alone. The senders it supplies are defaults that fill gaps.

It also never sets emailAndPassword.enabled. Configuring the email half will not turn on password auth for an app that did not ask for it.

AWS credentials

Credentials follow the standard @wraps.dev/email resolution chain. Pass anything WrapsEmailConfig accepts through ses:

wrapsAuthEmails({
  from: '[email protected]',
  ses: {
    region: 'us-east-1',
    roleArn: 'arn:aws:iam::123456789012:role/AcmeMail', // Vercel / GitHub Actions OIDC
  },
});

With nothing set, the AWS credential chain resolves as usual (env vars, ~/.aws/credentials, instance role).

Custom templates

The bundled templates are plain HTML with no React dependency, and carry no Wraps branding — they read as coming from your app. Override any of them:

wrapsAuthEmails({
  from: '[email protected]',
  templates: {
    verification: ({ user, url, appName }) => ({
      subject: `Confirm your ${appName} account`,
      html: renderMyEmail({ user, url }),
      text: `Confirm: ${url}`,
    }),
  },
});

Contact sync

On user creation the plugin upserts a contact and fires an event:

POST /v1/contacts/   { externalId: <user.id>, email, firstName, lastName, ... }
POST /v1/events/     { name: 'user.signed_up', contactId, properties: { method, provider } }

If the email already belongs to a contact — a newsletter subscriber converting, say — it patches that contact instead of failing. properties.method records how they signed up (email, oauth, passkey, magic-link, otp), with provider set for OAuth.

Consent

New contacts are subscribed to no topics by default. A signup is a transactional relationship, not marketing consent. Set topicSlugs only when your signup form actually asks:

wraps({
  apiKey: process.env.WRAPS_API_KEY,
  topicSlugs: ['product-updates'],
});

Attribution

Where a signup came from is known at the request, and gone by the time you query the contact. Turn it on and the plugin reads it off the signup request and stores it on both the contact and the event:

wraps({
  apiKey: process.env.WRAPS_API_KEY,
  attribution: true,
});

That's the whole setup. The contact gets utm_source, utm_medium, utm_campaign, utm_content, utm_term, ref, referrer, landing_page, timestamp, gclid, fbclid, and msclkid — whichever are present — and so does the user.signed_up event, so a workflow can branch on the campaign that produced the signup.

It reads a wraps_attribution cookie holding either JSON or a query string. Set it wherever you already handle first touch — in Next.js, middleware is the usual place:

// middleware.ts — first touch wins, so don't overwrite an existing cookie
if (!request.cookies.has('wraps_attribution')) {
  response.cookies.set('wraps_attribution', JSON.stringify(params), { maxAge: 30 * 24 * 3600 });
}

No cookie is fine too: the plugin falls back to the query string of the page that submitted the signup, which covers a visitor who lands on /signup?utm_source=hn and signs up on the spot.

Off by default, because it writes browser-supplied data to your contact records, and that should be a decision rather than something that starts happening on upgrade.

The key list is an allowlist, not a suggestion. The cookie is browser-writable, so anyone can put anything in it. Only known keys are kept, values are flattened to strings and capped at 512 characters, and nothing from the cookie can shadow the method, provider, or source the plugin reports on the event. Extend it deliberately:

import { DEFAULT_ATTRIBUTION_FIELDS } from '@wraps.dev/better-auth';

attribution: {
  cookieName: 'acme_attr',
  fields: [...DEFAULT_ATTRIBUTION_FIELDS, 'partner_id'],
  fromReferer: false,   // cookie only
  event: false,         // contact only, keep the event lean
  parse: (context) => ({ affiliate: context?.headers?.get('x-affiliate') }),
}

parse replaces every other source and skips the allowlist — it's your code. If it throws, you get an onError with stage: 'attribution' and the signup carries on without attribution.

Anything else off the request

properties and shouldSync both receive the Better Auth hook context, so anything on the request can shape the contact:

wraps({
  apiKey: process.env.WRAPS_API_KEY,
  properties: (user, context) => ({
    plan: 'free',
    invitedBy: context?.body?.inviteCode,
  }),
  shouldSync: (user, context) => context?.path !== '/admin/create-user',
});

properties wins over attribution on key collisions — explicit beats ambient.

Options

wraps({
  // --- sync ---
  apiKey: process.env.WRAPS_API_KEY,
  baseUrl: 'https://api.wraps.dev',
  eventName: 'user.signed_up',        // or false to skip the event
  topicSlugs: [],
  emailStatus: 'active',
  attribution: false,                 // true, or { cookieName, fields, fromReferer, parse, contact, event }
  properties: (user, context) => ({ plan: 'free' }),
  shouldSync: (user, context) => !user.email.endsWith('@internal.acme.com'),
  syncOnUpdate: true,                 // patch the contact on email/name change
  syncOnDelete: false,                // or 'unsubscribe' | 'delete'

  // --- email ---
  email: {
    from: 'Acme <[email protected]>',
    appName: 'Acme',
    appUrl: 'https://app.acme.com',
    invitationUrl: ({ id }) => `https://app.acme.com/join/${id}`,
    replyTo: '[email protected]',
    configurationSetName: 'acme-auth',
    brand: { logoUrl, primaryColor, supportEmail, footerText },
    templates: { /* per-template overrides */ },
    ses: { /* WrapsEmailConfig passthrough */ },
  },

  // --- behaviour ---
  waitUntil: (promise) => ctx.waitUntil(promise),  // Vercel / Cloudflare
  onContactSynced: ({ userId, contactId, created }) => {},
  onError: (error, { stage }) => logger.warn({ error, stage }),
});

Notes on correctness

Every signup path is covered. The plugin hangs off databaseHooks.user.create.after, not response-level after hooks. Better Auth skips after hooks on OAuth redirect responses, so a plugin that matches on /callback/* silently misses every Google and GitHub signup. Database hooks fire for all of them — including users created by an admin or by SCIM. Plugin hooks are additive, so your own databaseHooks still run.

Sync work is awaited. On Lambda the runtime freezes the moment the handler returns, so fire-and-forget background work never happens. Pass waitUntil if your platform has a real background primitive.

Auth never fails because of us. Every contact write and every send is wrapped. Failures go to onError and stop there — a Wraps outage or an SES throttle cannot break a signup.

Development

pnpm install
pnpm test
pnpm typecheck
pnpm lint
pnpm build

License

MIT