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

@cordwood/auth

v0.1.3

Published

Sessions, roles and tenant members on Firebase Auth: a verified session cookie, the 401 or 403 a route answers, invites that undo themselves.

Readme

@cordwood/auth

Sessions, roles and tenant members on Firebase Auth. The browser signs in with Firebase and sends its ID token once. From then on every request carries an HttpOnly cookie, and each route asks one question: may this request do this?

pnpm add @cordwood/auth @cordwood/firebase firebase-admin

Node 22 or later. Server only.

Set up

Name your roles and what each may do. The package knows neither.

import { createAuth, defineRoles } from "@cordwood/auth";
import { auth, firestore } from "@cordwood/firebase";

export const roles = defineRoles({
  admin: ["read", "write", "members.manage"],
  editor: ["read", "write"],
  viewer: ["read"],
});

export const cordwood = createAuth({
  auth: auth(),
  firestore: firestore(),
  roles,
  managePermission: "members.manage",
});

A tenant and a role are custom claims on the Firebase account (tenant, role). Only the server writes them.

Sign in

The browser signs in with the Firebase client SDK and posts its ID token. The route exchanges it for the cookie.

export async function POST(request: Request) {
  const { idToken } = await request.json();
  const exchanged = await cordwood.sessions.exchange(idToken);
  if (!exchanged.ok) {
    return Response.json({ error: exchanged.reason }, { status: 401 });
  }
  const response = Response.json({ session: exchanged.session });
  const { maxAge } = cordwood.cookieOptions;
  response.headers.append(
    "set-cookie",
    `${cordwood.cookieName}=${exchanged.cookie}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`,
  );
  return response;
}

exchange refuses with one of these reasons.

| Reason | Meaning | |---|---| | token | The ID token is expired, revoked or forged, or is no token at all. | | email_unverified | Nobody proved the address. | | stale_sign_in | The sign-in is older than 5 minutes, so the token may have been lifted from elsewhere. |

A server component reads the session from the cookie's value:

const session = await cordwood.sessions.read(cookieStore.get(cordwood.cookieName)?.value);
// { uid, email, tenantId, role } | null

It answers null when there is no cookie, it expired, it was revoked, or the account no longer holds the tenant or role the cookie grants.

A session lasts 5 days by default. Signing out calls cordwood.sessions.end(cookie), which revokes the user's refresh tokens and ends their sessions on every device. Clearing the cookie alone would leave a copy of it working.

Guard a route

export async function POST(request: Request) {
  const access = await cordwood.access.require(request, "write");
  if (!access.ok) {
    return Response.json({ error: access.reason }, { status: access.status });
  }
  const tenant = tenantRef(firestore(), access.tenantId);
  // access.uid, access.email and access.role say who is acting
}

| Status | Reason | Meaning | |---|---|---| | 401 | no_session | No cookie, or one that no longer verifies. | | 403 | no_tenant | A signed-in user no tenant has admitted. | | 403 | forbidden | The role does not hold the permission. | | 403 | cross_site | A write that another site's page sent with the user's cookie. |

The tenant comes from the verified cookie and from nowhere else.

A cookie is checked against the account on every request. Change a member's role or remove them, and their open sessions stop at the next request, not 5 days later.

Members

A tenant's members are kept at tenants/{tenantId}/members/{uid}.

Admit

const admitted = await cordwood.members.admit(
  access.tenantId,
  { email: "[email protected]", role: "editor", name: "Ana Lima" },
  {
    continueUrl: "https://app.example.com/welcome",
    deliver: (invite) =>
      email.send({
        to: invite.email,
        subject: "You were invited",
        text: `Set your password: ${invite.link}`,
      }),
  },
  access.uid,
);

The person gets a one-time link where they set a password. Opening it also proves the address. When deliver throws, the account, the claims and the roster row are undone and the error is rethrown.

For a sign-up whose own link already proved the address, pass { password } in place of continueUrl and deliver. That is also how a tenant gets its first member.

admit answers { ok: false, reason } for an address it will not take.

| Reason | Meaning | |---|---| | member | The address already belongs to this tenant. | | other_tenant | Another tenant holds it. Moving someone between tenants is a person's decision. | | disabled | The account was shut by someone other than this tenant. An invite does not reopen it. |

An existing account that had a password loses it on admission. Anyone can register an address before its owner arrives, so only the link proves who holds the mailbox.

List, change a role, remove

const people = await cordwood.members.list(tenantId);
await cordwood.members.changeRole(tenantId, uid, "viewer", access.uid);
await cordwood.members.remove(tenantId, uid, access.uid);

The last argument is who acts. They must hold managePermission, and nobody acts on their own membership, which keeps a tenant from losing its last manager. Two managers who demote or remove each other in the same instant leave one: the second is answered forbidden.

Removing disables the account, ends its sessions and deletes the roster row with everything stored under it. The same tenant can admit the person again later.

Not in this version

The browser side (the sign-in form, the Firebase client setup) and ready-made Next.js route handlers. Write the two routes above in your app for now.