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

@r3pos/ecom-next

v0.1.0

Published

Next.js adapter for the R3 Ecom Platform. Mount one App Router route handler and get shopper accounts, guest checkout and order history on the merchant's own storefront — sessions in httpOnly cookies, tokens never readable by browser JavaScript. Built on

Readme

@r3pos/ecom-next

The Next.js adapter for the R3 Ecom Platform.

You run your own storefront on Next.js. You install this, mount one route handler, and you have shopper accounts, guest checkout and order history. The adapter is your backend.

npm install @r3pos/ecom-next

Requires Next.js 15+ (App Router) and React 18.2+, both peer dependencies.


Three files

1. Build the adapter once, server-side.

// lib/r3.ts
import { createR3Ecom } from '@r3pos/ecom-next';

export const r3 = createR3Ecom({
  apiKey: process.env.R3_SECRET_KEY!, // r3_sk_live_… — NEVER a NEXT_PUBLIC_ variable
  baseUrl: 'https://api.r3pos.com',
  tenantId: process.env.R3_TENANT_ID!,
});

2. Mount the route handler.

// app/api/r3/[...r3]/route.ts
import { r3 } from '@/lib/r3';

export const { GET, POST } = r3.handlers();

3. Mount the middleware. Not optional in practice — see Why the middleware matters.

// middleware.ts
import { r3 } from '@/lib/r3';

export const middleware = r3.middleware;
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

Then read the session anywhere on the server:

// app/layout.tsx
import { r3 } from '@/lib/r3';
import { R3Provider } from '@r3pos/ecom-next/react';

export default async function Layout({ children }: { children: React.ReactNode }) {
  const session = await r3.getShopperSession();
  return (
    <html>
      <body>
        <R3Provider session={session}>{children}</R3Provider>
      </body>
    </html>
  );
}

and act on it in any client component:

'use client';
import { useShopper } from '@r3pos/ecom-next/react';

export function AccountBadge() {
  const { session, loading, signIn, signOut } = useShopper();

  if (session.status === 'guest') {
    return <SignInForm onSubmit={(email, password) => signIn({ email, password })} />;
  }
  return (
    <button onClick={() => signOut()} disabled={loading}>
      {session.shopper.displayName}
    </button>
  );
}

What this actually guarantees

The shopper is the platform's shopper

Authentication runs against the platform's existing /consumer/auth/* endpoints. The person who collects stamps at your counter and the person who orders from your website are one account, one email, one loyalty balance, one set of saved cards. This adapter does not invent a storefront-only identity, because a second identity would fork all of that away from their in-store record.

No token is ever readable by browser JavaScript

The access token and the rotating refresh token live in cookies this adapter owns:

| | value | | --- | --- | | HttpOnly | always, on all four cookies — including the profile and guest cookies, so an XSS bug cannot read a shopper's email either | | SameSite | Lax | | Secure | on in production, off otherwise (so http://localhost works) | | Path | /, or whatever you set as cookiePath |

There is no option to weaken any of that. Nothing in this package writes a token to localStorage, to a script-readable cookie, or into a response body — GET /session and the object handed to React carry profile data only. That is asserted by a named test, on the real Set-Cookie header and the real response body.

Cross-origin state changes are refused

Every mutating route checks the request's Origin (falling back to Referer) against your storefront's own origin plus anything you list in trustedOrigins, and fails closed — a request with neither header is a 403.


Routes

Mounted at basePath (default /api/r3).

| Route | Upstream | Cookies touched | | --- | --- | --- | | POST /login | POST /consumer/auth/login | sets access + refresh + profile; fills guest email | | POST /google | POST /consumer/auth/google | sets access + refresh + profile; fills guest email | | POST /register | POST /consumer/auth/register | none — registration never opens a session | | POST /logout | POST /consumer/auth/logout-all (only with { everywhere: true }) | clears access + refresh + profile | | GET /session | POST /consumer/auth/refresh (only when stale) | may re-set access + refresh + profile; mints guest | | POST /magic-link/request | POST /consumer/auth/request-login | none | | POST /magic-link/confirm | POST /consumer/auth/confirm-login | sets access + refresh + profile | | POST /password-reset/request | POST /consumer/auth/request-reset | none | | POST /password-reset/confirm | POST /consumer/auth/confirm-reset | clears access + refresh + profile | | POST /verify-email | POST /consumer/auth/verify-email | sets access + refresh + profile | | POST /verify-email/request | POST /consumer/auth/request-verification | none |

Failures come back as the platform's envelope — { "error": "<code>", "message": "…" } — with the platform's status. On the server they are the same typed errors @r3pos/ecom throws, re-exported here so one catch covers everything:

import { isConflictError, isRateLimitError } from '@r3pos/ecom-next';

Two notes on behaviour you should not "fix"

Registration does not sign anyone in. The platform gates sign-in behind a verified email, so POST /register returns { verificationRequired, email } and no session. Send the shopper to their inbox; the emailed link lands on POST /verify-email, which verifies and signs them in. (verificationRequired is false in exactly one case: the address already belonged to a verified guest-checkout shopper who has just set a password, so they can sign in immediately.)

Login is deliberately uninformative. An unknown email and a wrong password produce the same 401, after the platform has done the same argon2 work in both cases. The adapter passes that through byte for byte and adds nothing — /magic-link/request, /password-reset/request and /verify-email/request likewise always answer { ok: true } whether or not the address exists. Do not add a "no account found" branch on top: it would turn all of that back into an account-enumeration oracle.

POST /logout

Clears this browser's cookies, with no upstream call. Pass { everywhere: true } to also revoke the shopper's sessions on their other devices. The guest cookie survives either way — signing out is "this is not my account right now", not "forget me".


Sign in with Google

Google sign-in produces the same session as email and password. Same three httpOnly cookies, same flags, same refresh path — there is no second session mechanism, because a second one would be a second set of rules to keep in step and the neglected one would be the leak.

This package does not bundle or wrap Google's SDK. You render the button and hand the adapter the ID token Google gives you. That keeps a third-party script out of every storefront that installs this, and leaves you free to use the button, One Tap, a custom flow, or a native app's Google sign-in — they all produce the same ID token.

1. Get the client ID

Create an OAuth 2.0 Client ID of type Web application in the Google Cloud console, with your storefront's origin as an Authorized JavaScript origin. It looks like 123456789-abc.apps.googleusercontent.com.

Then register it in R3 under Settings → Developers → Google sign-in. That registration is what makes the tokens your site obtains recognisable: the API checks the token's aud claim against the client ids it knows, and an unregistered aud is refused with 401, always.

If you cannot run a Google project, ask R3 to enable R3's own client for your domain. Leaving the field blank does not do this by itself: using R3's client means R3 has to add your storefront's domain to that client's Authorized JavaScript origins, which only R3 can do, so it is granted per store rather than assumed. Until you register your own or R3 enables theirs, Google sign-in is off for your store. Two things to know before choosing R3's: Google's consent screen will show R3's name rather than yours, and changing your domain later means telling R3 first.

2. Read the client ID rather than hardcoding it

import { r3 } from '@/lib/r3';

// A Server Component, a layout, or anywhere on the server.
const googleClientId = await r3.googleClientId();
// string  → render the Google button with it
// null    → this store has no Google sign-in; render NO button

r3.googleClientId() is one GET /ecom/v1/merchant read. The server resolves which client is in force (yours if registered, else R3's if enabled for you, else none), so switching Google projects later is a settings change rather than a redeploy — and your site and the platform cannot end up disagreeing about it, which otherwise surfaces as every sign-in failing with a bare 401.

Handle the null. It is a normal state, not an error, and there is nothing to fall back to: a button pointed at a client your store is not entitled to use dies inside Google with an opaque origin error your users cannot diagnose and you cannot see.

The client ID is public: it ships in the page by design, so passing it to a client component is fine. There is no Google client secret in this flow — R3 verifies ID tokens against Google's public keys and never asks for one. (Your R3 secret key still never goes near a NEXT_PUBLIC_ variable.)

3. Render the button and pass the token through

'use client';
import Script from 'next/script';
import { useShopper } from '@r3pos/ecom-next/react';

export function GoogleButton({ clientId }: { clientId: string }) {
  const { signInWithGoogle } = useShopper();

  return (
    <>
      <Script
        src="https://accounts.google.com/gsi/client"
        onReady={() => {
          google.accounts.id.initialize({
            // The id from step 2 — passed in as a prop, not read from an env
            // var, so the platform stays the one place that decides it.
            client_id: clientId,
            // `credential` IS the ID token. It is the only Google value that
            // crosses into this package, and it crosses once.
            callback: ({ credential }) => void signInWithGoogle({ idToken: credential }),
          });
          google.accounts.id.renderButton(document.getElementById('g-btn')!, { theme: 'outline' });
        }}
      />
      <div id="g-btn" />
    </>
  );
}

signInWithGoogle posts the token to your own POST /google route, which forwards it to the platform along with the tenantId this adapter was configured with — R3 needs to know which store's settings to check, and a browser does not get to choose that, so a tenantId in the incoming request body is ignored. The platform verifies it server-side — RS256 against Google's JWKS, plus iss, aud, exp and iat — and answers with an ordinary consumer session. The R3 tokens go straight into the httpOnly cookies; the browser never holds one, and the ID token is never stored, logged or echoed back.

What the platform does with the account

  1. The token's sub already matches a shopper ⇒ that is the account.
  2. Otherwise, if Google says the email is verified and it matches an existing shopper ⇒ the accounts are linked. Email is the identity in this platform, so creating a second shopper for someone who already has orders, stamps and saved cards under that address would strand all of it behind an account they cannot reach.
  3. Otherwise ⇒ a new shopper, with the email already verified.

An unverified Google email is refused with 403 email_unverified — anyone can attach an arbitrary address to a Google account, and honouring one would hand over any shopper's identity for the price of typing their email. Handle that code the same way you already handle it on password login.


Why the middleware matters

Next.js refuses cookie writes inside a React Server Component. The platform revokes a refresh token the moment it is exchanged, with no grace window. Put those together and refreshing from a Server Component would hand the browser a token the server has already killed.

So the session engine refreshes only where it can persist the result. That means:

  • With the middleware mounted — it runs before the render, in a context that can write, and rolls the 15-minute access token over ahead of time. Everything downstream just works.
  • Without it — a Server Component with an expired access token still correctly reports the shopper as signed in (they hold a live refresh token), but r3.orders.list() from that render will throw session_refresh_required, with a message naming this fix. Nothing is silently broken and nothing is silently destroyed.

Refreshes are single-flight: five components reading the session on one page perform one refresh, not five. Five would mean four replays of a revoked token, which the platform answers by revoking the shopper's entire chain — signing them out of every device.


Sessions

const session = await r3.getShopperSession();

ShopperSession is a discriminated union, so session.shopper does not typecheck until you have narrowed:

if (session.status === 'authenticated') {
  session.shopper.displayName; // ✅
  session.guestContinuity;     // were their guest orders placed with this email?
} else {
  session.email;               // the address a guest checkout recorded, if any
}

It carries no credential, which is what makes it safe to pass into a Client Component, log, or serialise.

Cached per request via React's cache, so reading it in five components costs one resolution.


Guests

A guest gets a stable id in its own cookie, minted on first need:

const guestId = await r3.ensureGuestId();

It returns null when called from a Server Component that has no guest cookie yet — Next.js will not let a Server Component write the cookie that would make the id stick, and an id that changes on every render is worse than none. Call it from a Server Action or Route Handler, or mount the middleware, and it always returns a string.

Guest → account continuity

The platform already keys a shopper's orders on email: a guest checkout resolves (or creates) the consumer row for that address, and registering later with the same address claims that same row. Their guest orders were always theirs.

The adapter's job is only to carry the email through so your storefront can say so. Record it at checkout:

'use server';
await r3.rememberGuestEmail(email);

and when that shopper signs in, the session reports guestContinuity: true. There is no link table here, deliberately — it would be a second, weaker source of truth for something the platform already decides correctly.


Order history

Server-side only. The browser never sees the consumer token.

// app/account/orders/page.tsx — a Server Component
import { r3 } from '@/lib/r3';

export default async function OrdersPage() {
  const orders = await r3.orders.list({ limit: 20 });
  return <OrderList orders={orders} />;
}
  • r3.orders.list(params?) — this merchant's orders for the signed-in shopper, newest first, with full line items.
  • r3.orders.get(id) — one order's full receipt. An order the shopper placed at a different café on the platform is a 404 here: true for your storefront, and the only answer that does not leak where else they shop.

Both throw R3EcomAuthError (not_signed_in) when nobody is signed in.


Saved addresses

Server-side only, like order history. A signed-in shopper's address book:

// app/checkout/page.tsx — a Server Component
import { r3 } from '@/lib/r3';

export default async function CheckoutPage() {
  const session = await r3.getShopperSession();
  if (session.status !== 'authenticated') return <GuestCheckout />;

  const addresses = await r3.addresses(session).list();
  return <AddressPicker addresses={addresses} />;
}

| | | | --- | --- | | list() | every saved address — default first, then newest first. Unpaginated | | create(input) | line1 and country required; country is ISO-3166-1 alpha-2 in any case. The first address a shopper saves becomes their default whatever isDefault says | | update(id, changes) | an omitted field is left alone; line2 and phone accept null to clear | | remove(id) | deleting the default promotes the next-newest, so the book is never left without one | | setDefault(id) | promotes one and demotes the previous, in one operation |

A guest has no address book, and the compiler knows

r3.addresses(session) takes an AuthenticatedShopperSessionnot the ShopperSession union. Passing an un-narrowed session is a build failure, not a 401 discovered later on somebody's checkout page:

const session = await r3.getShopperSession();
r3.addresses(session);                                  // ❌ does not compile
if (session.status === 'authenticated') r3.addresses(session); // ✅

The book belongs to the shopper, not to you

An address is the consumer's, not the merchant's: a person's home address is the same at every café on the platform, and there is deliberately no merchant-facing route into that table. You see an address when the shopper attaches one to an order, at which point it is your order data.

Ownership is enforced upstream, on the consumer id in the verified token rather than on anything in the URL. Another shopper's address id is a 404 — indistinguishable from an id that never existed, and passed straight through here rather than softened into null. "Not found" and "not yours" must stay the same answer.


Reorder

r3.draftReorder(orderId) rebuilds a past order as a priced draft.

const draft = await r3.draftReorder(order.id);

if (draft.hasPriceChanges || draft.hasUnavailableLines) {
  return <ReviewChanges draft={draft} />;   // show them, then let the shopper decide
}

It creates nothing and charges nothing. There is no order id on a ReorderDraft — only sourceOrderId, naming the order it was rebuilt from. Placing it is a separate, explicit step:

await r3.ecom.orders.create({ items });   // built from the draft's lines

That separation is the whole point. Both shortcuts it refuses are money bugs: re-selling a six-month-old basket at last year's stored prices loses the merchant money that grows with the age of the order, and re-pricing it silently charges the shopper a total they never saw. So every line is re-resolved server-side against today's catalog, by the same pricing core that prices a live cart, and the differences come back for you to show:

| per line | | | --- | --- | | unitPriceCents | today's price. null when the line cannot be re-bought | | previousUnitPriceCents | what the original order charged. Always present, including on unavailable lines | | priceChanged | the two differ — show both numbers | | available / archived | can it be re-bought; did the merchant retire it, or is it just out of stock today | | unavailableReason | product_deleted, product_archived, product_unpublished, product_unavailable, modifier_unavailable, invalid_product, line_invalid | | selectedOptions | the historical modifier choices re-resolved onto today's option ids — post these back verbatim to place the cart |

subtotalCents / taxCents / totalCents cover the available lines only, at your current tax rate, and carry no tip — a tip is chosen at checkout. hasUnavailableLines and hasPriceChanges are the two booleans a UI branches on.

Unlike order history, this runs on your key (orders:write), because it prices against your live catalog — so it goes over the same transport as the rest of r3.ecom.


The rest of the platform

r3.ecom is the full @r3pos/ecom server SDK on the same configuration — catalog, inventory, orders, webhooks, merchant profile:

const { data: products } = await r3.ecom.catalog.products.list({ limit: 50 });
const order = await r3.ecom.orders.create({ items });

Configuration

createR3Ecom({
  apiKey,                  // required — SECRET key (r3_sk_…). A publishable key throws.
  baseUrl,                 // required — API origin, no path
  tenantId,                // required — scopes order history to your store

  basePath,                // default '/api/r3'
  trustedOrigins,          // extra origins allowed to make mutating requests
  cookiePrefix,            // default 'r3_shopper'
  cookieDomain,            // unset ⇒ host-only, the safer default
  cookiePath,              // default '/'
  secure,                  // default: on in production
  sessionMaxAgeSeconds,    // default 30 days, matching the platform's refresh TTL
  guestMaxAgeSeconds,      // default 1 year
  refreshSkewSeconds,      // default 60 — refresh this long before expiry
  timeoutMs,               // default 15000
  forwardShopperIp,        // default true — see below
  fetch, now, mintGuestId, // injectable, for tests
  userAgent,
});

Build one per process and share it. Two adapters cannot coordinate a rotation between them.

apiKey must be the secret key

A publishable key throws at construction. It is refused rather than tolerated because this adapter is your backend: it holds the session cookies and reads order history, which needs the orders:read scope a publishable key can never hold. A storefront built on one would come up, serve a catalog, take an order, and then 403 the first time a shopper opened their order history — weeks later, looking like a permissions bug.

Keep it in a server-side environment variable. Never one prefixed NEXT_PUBLIC_, which Next.js inlines into the browser bundle.

forwardShopperIp

On by default. Forwards the shopper's address to the platform so its per-IP rate limits count per shopper, rather than lumping your entire storefront into the one bucket belonging to your server.

The address comes from the incoming request's X-Forwarded-For (first hop) or X-Real-IP, so it is exactly as trustworthy as those headers are in your deployment: authoritative behind a proxy that sets them (every managed Next.js host does), client-controlled on an app exposed directly to the internet. Turn it off in the latter case — the cost is one shared rate-limit bucket for every shopper, which a busy storefront will notice.

cookieDomain

Leave it unset unless you need one session across subdomains. Setting it to a parent domain makes a hostile sibling subdomain same-site, so SameSite=Lax stops covering you there and the origin check becomes your only CSRF control — which is precisely why that check exists.


CSRF: strict origin checking

Every state-changing request must carry an Origin (or, failing that, a Referer) whose origin is your storefront's own or one you listed in trustedOrigins. Anything else, including a request with neither header, is a 403 — before the body is read and before anything upstream is touched.

Why not a double-submit token? It has to be readable by the page that echoes it, which means either a cookie without HttpOnly — punching a hole in the exact property this package exists to guarantee — or an extra round trip before every sign-in, which is a piece of wiring every merchant then has to get right. Origin checking needs neither: the browser sets the header, script cannot forge it, and mounting the route handler is the whole integration.

It is also not the only control. The session cookies are SameSite=Lax, so a cross-site POST does not carry them in any current browser and the forged request arrives unauthenticated. The origin check covers the case SameSite=Lax does not: a hostile page on a sibling subdomain, which matters as soon as you set cookieDomain.

If a proxy in front of your app strips both headers, you will see 403s — the correct, loud failure. Add your storefront's public origin to trustedOrigins.


Testing your integration

Everything is injectable, so your own tests need no network:

const r3 = createR3Ecom({
  apiKey: 'r3_sk_test_…',
  baseUrl: 'https://stub.invalid',
  tenantId: 'test-tenant',
  fetch: myFakeFetch,
  now: () => 1_800_000_000_000,
  mintGuestId: () => 'guest-1',
  secure: true,
});

Coming next

Changing a password from inside a signed-in session is not exposed yet, though the platform endpoint exists; it is scoped for the next pass.


License

MIT © R3 Lab