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

@chattee.ai/auth

v1.6.0

Published

Authentication for applications built on Chattee — browser client and Node server helpers.

Readme

@chattee.ai/auth

Authentication for applications built on Chattee.

Identity lives in a chattee_auth schema inside your own Postgres database, so users join and foreign-key like any other table. There is no mirror, no webhook, no sync, and no bulk-hydrate step.

Nothing to install and nothing to configure: adding the auth resource to a project puts this package in your dependencies for you. The browser client talks to your own origin, and the server helpers use the database connection you already have.


Browser

import { auth } from '@chattee.ai/auth';

await auth.signUp({ email, password, name });
await auth.signIn({ email, password });
await auth.signOut();

const { user } = await auth.getSession();   // { user: null } when signed out

const stop = auth.onAuthChange(({ user }) => setUser(user));

Passwordless and recovery:

await auth.requestMagicLink({ email });        // signs up or signs in
await auth.requestPasswordReset({ email });
await auth.confirmPasswordReset({ token, password });   // token comes from ?token= in the link
await auth.requestEmailVerification();         // for the signed-in user

Signup already sends the confirmation email — do not build one

signUp mints a verification token and mails the new account a confirmation link, on the platform's side, before it returns. You do not call requestEmailVerification() after a signup, you do not compose the message, and you do not need a route for the link: the link points at the platform, which marks the address verified and redirects the browser back to your app with ?chattee_auth=verified (or verify_failed).

requestEmailVerification() exists for the re-send case only — a signed-in user who never got the first one. Put it behind a "resend confirmation" button, not in your signup handler.

Two consequences worth designing for:

  • A new account is usable immediately and unverified. The session is issued whether or not the mail went out, because the app's owner may not have connected an email provider yet. If a feature genuinely requires a confirmed address, read email_verified from chattee_auth.users in your backend (your role has SELECT on that table) and degrade there — do not block sign-in, or a freshly deployed app locks out everyone who joins before its owner finishes setting it up.
  • The owner can switch the confirmation off in the Data tab, for an app whose users are created by an administrator. Nothing in your code changes; no mail is sent.

There is no API key, and no token for you to store

Every call goes to /_chattee/v1/auth/* on your own domain. The platform derives which project you are from the hostname the request already arrived on, so this module holds no secret and is safe to bundle.

The session is an opaque token in an HttpOnly cookie. No script can read it — including one an end user managed to inject into your app. Do not build a token store: there is nothing to put in it, and localStorage is exactly what HttpOnly exists to avoid.

CSRF is handled for you: each call echoes the readable chattee_csrf cookie in an X-Chattee-CSRF header, which a cross-site form post cannot do. This is why you should use these functions rather than hand-rolled fetch calls — a hand-rolled fetch to /_chattee/v1/auth/* cannot produce that echo, and every write it attempts is refused.

The cookie is issued by the first GET that reaches the platform, so a page that posts before it has read anything has none yet. You do not have to arrange for that: a call refused for a missing token fetches /config (which mints one) and retries itself, once. If the token still does not arrive the browser is refusing to keep it — cookies blocked for the site, or an embedded page with third-party cookies off — and you get cookie_blocked, which says so, instead of a "reload the page" the reload cannot fix.

Sign-in errors are vague on purpose

invalid_credentials and signup_failed are returned identically for a wrong password, an unknown address, a taken address and a disabled account. Do not render something more specific — a form that distinguishes them is an account-enumeration tool aimed at your own users. The same is true of requestMagicLink and requestPasswordReset, which always resolve.

In the Chattee preview, a session does not carry to a new tab

The preview renders your app in an iframe, making its cookies third-party — so they are Partitioned (CHIPS) and scoped to the embedding page. Open the app on its own domain and sessions behave normally. This is correct behaviour, not a bug.


Rendering the login page from server config

What buttons to show and whether a challenge is on are settings the owner changes, not things to hardcode. Ask:

const { oauthProviders, turnstileSiteKey, emailAvailable } = await auth.getConfig();

oauthProviders lists only the providers this project has actually configured — offering a button that leads to "sign-in with GitHub is not set up for this app" is worse than offering none.

emailAvailable — whether anything you send will arrive

Gate every email-dependent affordance on this, and there are three:

{emailAvailable && <a href="/forgot-password">Forgot password?</a>}
{emailAvailable && <button onClick={magicLink}>Email me a sign-in link</button>}

...and the third is the "change my email address" control in account settings.

You cannot discover this by trying. requestPasswordReset and requestMagicLink always resolve, whatever happened, and the server answers { sent: true } even when the message went nowhere. That is deliberate — a truthful answer on an endpoint any stranger can call would tell them which addresses have accounts — but it means a "Forgot password" link rendered without this check leads to a form that says "check your inbox" and is a dead end forever.

It is false until the app's owner connects an email provider in the Data tab, which is normally some time after the app is first built. So it flips from false to true during the life of a project: read it when the page loads, do not cache it across sessions, and never bake the decision in at build time.

OAuth

auth.startOAuth('google');   // full navigation, not a fetch

It must be a real navigation: the flow is a redirect chain through the provider's consent screen and back, and the anti-CSRF state travels in a cookie the browser only sends on the return leg. The user lands back on your app at /?chattee_auth=signed_in (or oauth_failed, oauth_cancelled), already signed in.

The owner registers the callback URL at the provider. It is listed for them in the Data tab, and it changes if the app's subdomain or custom domain changes — a provider rejects any redirect URI it was not given in advance.

Turnstile

import { renderTurnstile } from '@chattee.ai/auth';

const { turnstileSiteKey } = await auth.getConfig();
if (turnstileSiteKey) {
  const turnstileToken = await renderTurnstile(boxElement, turnstileSiteKey);
  await auth.signUp({ email, password, turnstileToken });
}

Get the token immediately before submitting, not on page load: it is single-use and short-lived, and one produced ten minutes earlier is rejected by a user who cannot tell why.

Pass turnstileToken to signUp, signIn, requestMagicLink and requestPasswordReset — the four endpoints an anonymous stranger can call in a loop. The confirm endpoints are not challenged: they already require a single-use token from a link the user clicked in their inbox.

The keys are Chattee's, so there is nothing to register and nothing to configure. If the owner has the toggle off, turnstileSiteKey is null and you skip the widget entirely.


Server

import { createAuthServer } from '@chattee.ai/auth/server';
import { pool } from './db.js';

const auth = createAuthServer({ query: (sql, params) => pool.query(sql, params) });

app.get('/api/me', auth.requireAuth(), async (req, res) => {
  const { rows } = await pool.query(
    'SELECT id, email, name FROM chattee_auth.users WHERE id = $1', [req.session.userId]);
  res.json(rows[0]);
});

app.get('/api/admin', auth.requireRole('admin'), handler);

| method | purpose | |---|---| | verifyRequest(req) | → { userId, expiresAt } or null | | requireAuth() | Express middleware; 401 when signed out | | requireRole(role) | 401 when signed out, 403 without the role | | rolesOf(userId) / hasRole(userId, role) | plain reads | | invite(email, { role, message }) | mint and send an invitation |

Verification is one indexed local read, not a network call — so signing out, disabling an account or removing a role takes effect on the very next request. No JWT, no JWKS, no cache, no revocation window.

Ownership predicates are still yours

requireAuth() says a user is signed in. It does not say the rows you are about to return belong to them. Every query that reads user data needs its own predicate:

await pool.query('SELECT * FROM notes WHERE user_id = $1', [req.session.userId]);

Leaving that out is the single most common way a generated app leaks data, and no middleware can catch it.

Administering users

The writes your database role cannot do. These go to the platform, not to your database.

await auth.createUser(email, { password, name, role });  // nothing is emailed
await auth.invite(email, { role, message });             // emails a single-use link instead
await auth.disable(userId);                              // reversible; ends every live session
await auth.assignRole(userId, 'admin');
await auth.revokeRole(userId, 'admin');
await auth.erase(userId, currentEmail);                  // IRREVERSIBLE

Every one of these holds CHATTEE_PROJECT_TOKEN, so every one is an admin capability. Holding that token is what proves the call came from your server rather than from a browser — never put one on an unauthenticated route. The platform checks that the caller is your app; deciding who may call it is yours, and requireRole('admin') is usually the answer.

  • createUser vs invite differ in who proves the address. An invitation mails a single-use token and the click is the proof; createUser creates the account outright, and mails nothing — importing a list must not become mailing a list. Omit password for an account that cannot be signed into with one; magic links and OAuth still work.
  • disable is reversible and ends every live session, so it means what you expect rather than "cannot sign in again, but is still signed in everywhere". It is the right first response to abuse. erase is not.
  • erase takes the user's current email as a second argument and refuses without it. Not ceremony: you can SELECT it, so it costs one field you already have, and it turns the realistic mistake — a stale or wrong id in a delete-my-account handler — from destroying somebody else's data into an error you can handle. No restore undoes an erasure; the platform re-applies every recorded erasure after a restore, on purpose.
  • Roles are free-form strings with no declaration step. Spell one the same way everywhere: a mistyped role simply never matches, with no error anywhere.

Your database role

Postgres enforces the boundary — not this library:

| table | your app role | |---|---| | chattee_auth.users | SELECT, plus UPDATE on name and avatar_url only | | chattee_auth.user_roles | SELECT | | chattee_auth.active_sessions | SELECT (a view: enough to verify a token, never to mint one) | | credentials, sessions, email_tokens, oauth_links | nothing at all |

So this works:

UPDATE chattee_auth.users SET name = $1 WHERE id = $2;

and this is refused by the database, because both need verification and audit:

UPDATE chattee_auth.users SET email = $1 WHERE id = $2;      -- ✗
UPDATE chattee_auth.users SET disabled = true WHERE id = $1; -- ✗

A SQL injection in your code cannot reach a password hash — because Postgres refuses, not because this library was careful.

Foreign keys work normally

CREATE TABLE posts (
  id serial PRIMARY KEY,
  author_id uuid NOT NULL REFERENCES chattee_auth.users(id),
  body text NOT NULL
);

SELECT p.*, u.name FROM posts p JOIN chattee_auth.users u ON u.id = p.author_id;

Always schema-qualify chattee_auth.* — never a bare users. Your app connects through a transaction pool where SET search_path does not survive between statements, so an unqualified name resolves against your own schema and fails intermittently under load rather than in testing.

Always filter erased users

Users are never hard-deleted; the row survives so your foreign keys stay valid. Erasure sets deleted_at and blanks the personal columns.

SELECT id, name FROM chattee_auth.users WHERE deleted_at IS NULL;

Omit that and erased users reappear in your UI as blank rows.


Roles

Free-form strings in chattee_auth.user_roles — no declaration step, and you can invent one at any time. The cost is that requireRole('admni') never matches and never errors, so the library logs a warning the first time you check a role nobody in the project holds. The Data tab lists roles in use for the same reason.

The platform owns who holds a role. What a role may do is your business logic, and stays in your code.


Errors

Both halves report failures with the same stable code, on the same ChatteeAuthError class — so err instanceof ChatteeAuthError holds whichever entry point you imported it from.

Two of these are never thrown. requireAuth() and requireRole() answer with a 401 or 403 whose JSON body carries code, because an unhandled throw inside a request handler is a 500 and a signed-out user is not a server error. Read them off the response, not out of a catch.

| code | meaning | whose problem | |---|---|---| | invalid_credentials | wrong email or password | the user's | | signup_failed | the account could not be created | the user's | | password_too_short | shorter than 8 characters | the user's | | email_invalid | not a usable address | the user's | | token_invalid | reset link expired or already used | the user's — send a new one | | csrf_failed | CSRF cookie and header disagree | reload the page | | csrf_missing | no CSRF cookie was sent at all | handled for you — recovered automatically | | cookie_blocked | the browser will not keep the sign-in cookie | the user's browser, or the site's cookie settings | | not_signed_in | no session where one is required — answered as a 401 body by requireAuth(), not thrown | yours | | forbidden | signed in, but without the role — answered as a 403 body by requireRole(), not thrown | the user's | | auth_not_provisioned | this project has no managed database | ask the agent to add auth | | email_not_connected | mail cannot be sent yet | the owner, in the Data tab | | not_configured | the environment carries no Chattee configuration | redeploy | | unreachable | the platform could not be reached | transient; retry |

err.retryable is true only where trying again can plausibly succeed — today, unreachable. It is available on both halves; before 1.6.0 it was on the browser half only and read undefined on anything the server helpers threw.


Install

npm install @chattee.ai/auth

Requirements

Node 18+ (for global fetch and node:crypto), and a project with the postgres managed resource — identity is a schema in that database. Projects still running a per-project database-postgres container are grandfathered and cannot use auth.

Release notes

1.6.0

Additive. Nothing you wrote needs to change, and both entry points still export ChatteeAuthError.

One ChatteeAuthError, shared by both halves. @chattee.ai/auth and @chattee.ai/auth/server each declared their own class, so err instanceof ChatteeAuthError was false for half the errors in any app that used both — a server-rendered app, or anything that renders a sign-in form and also protects an API. They now import one module, so the check works whichever half threw.

err.retryable works on the server helpers. It existed only on the browser half, so the property the documentation tells you to branch on read undefined on exactly the calls that can fail transiently.

The error table no longer claims not_signed_in and forbidden are thrown. They never were: requireAuth() and requireRole() answer with a 401/403 whose body carries the code, because an unhandled throw in a request handler is a 500. The documentation now says so.

1.5.0

Additive. Nothing you wrote needs to change.

A call refused for a missing CSRF token now recovers itself. The chattee_csrf cookie is issued by the first GET that reaches the platform, so a page that posts before it has read anything has none — a sign-up form on a route that never called getSession() or getConfig(), for instance. That used to fail with csrf_failed and the advice to reload, which a page in that state would simply repeat. The client now fetches /config (which mints the token and nothing else) and retries the original call once. Once, not in a loop: retrying forever would hide a genuinely misconfigured server and would double every request under the attack the check exists to stop.

And when the token still does not arrive, the error finally says why. A new cookie_blocked code is thrown when the platform issued a cookie and the browser did not keep it — cookies disabled for the site, an embedded page with third-party cookies off, or a server whose Set-Cookie the browser rejects. The old message told the user to reload a page that could never work, and the same symptom on the platform side once cost days to diagnose.

csrf_missing also joins the error table: the platform now distinguishes "no cookie was sent at all" from "the cookie and the header disagree", which are different problems with different fixes. Both are handled for you; you should not need to branch on either.

1.4.0

Additive. Nothing you wrote needs to change.

getConfig() now reports emailAvailable — whether the project can actually send mail, meaning the email resource is attached and its owner has connected a provider. Gate the "Forgot password" link, magic-link sign-in and the change-email control on it.

This closes a gap the browser could not see around. requestPasswordReset and requestMagicLink always resolve and the server answers { sent: true } even when the message went nowhere — a deliberate refusal to become an account-enumeration oracle, which also meant an app had no way to learn that its own password reset was inert. Rendered without the check, the link led to a form that said "check your inbox" and was a dead end.

The field is false when absent, so an app built against this version keeps behaving safely on an older backend: it simply does not offer the affordance.

Signup also mails a confirmation link now, on the platform's side. That is a server change rather than a client one, but it changes what you should write: do not call requestEmailVerification() after signUp(), and do not build a route for the confirmation link — the platform owns both. See "Signup already sends the confirmation email" in the README.

1.3.0

Adds AGENT.md to the published package. It is the model-facing core of this README — the same prose, without the install/requirements/licence/release-note sections — and it is what Chattee's build agent is given when a project uses this resource. Publishing it means the copy in node_modules always describes the version actually installed.

Nothing you wrote needs to change; there is no API change in this release.

1.2.1

Fix. No API change.

CHATTEE_API_URL is now accepted with or without the /_chattee/v1 prefix. The server-channel methods — invite, createUser, disable, erase, assignRole, revokeRole — used to prepend the prefix themselves, while the platform binds it with the prefix already included, so every call went to /_chattee/v1/_chattee/v1/auth/... and came back 404. Combined with the second half of the fix (the auth resource now binds CHATTEE_API_URL and CHATTEE_PROJECT_TOKEN, which previously only the file-storage resource minted), this is what makes the server channel reachable at all — including invite, which has been shipped and unreachable since 1.1.0.

Nothing you wrote needs to change, and an app already running against either form of the binding keeps working — the URL is normalised before the request is built.

1.2.0

Additive only — no breaking changes, and every 1.1.0 call still works unchanged.

Five server-side administration helpers on createAuthServer(...). Each writes chattee_auth tables your database role has no grants on, so they go to the platform rather than to SQL, and each is authorized by CHATTEE_PROJECT_TOKEN alone. Your app decides who may call them — never expose one on an unauthenticated route.

  • createUser(email, { password, name, role }) — creates an account outright. Unlike invite it mails nothing, so importing a list does not become mailing a list. Omit password for an account reachable by magic link or OAuth but not by password.
  • disable(userId, { disabled = true }) — reversible, and it ends every live session, so it means what you expect rather than "cannot sign in again but is still signed in everywhere". Returns sessionsRevoked.
  • erase(userId, email) — irreversible: no restore brings an erased user back, because the platform re-applies every recorded erasure after a restore. The row survives as a tombstone so your foreign keys stay valid, so keep filtering WHERE deleted_at IS NULL. The email argument is required and must match the account — it turns a stale id in a delete-my-account handler from destroying somebody else's data into an error you can handle.
  • assignRole(userId, role) / revokeRole(userId, role) — free-form strings, readable straight back with rolesOf.

Errors carry the platform's own code (user_unavailable, email_mismatch, not_configured, …), because those call for different handling and a bare status would not say which happened.

Requires a Chattee backend from the same release or later — these endpoints return 404 against an older one.

1.1.0

Additive only — no breaking changes, and every 1.0.0 call still works unchanged.

  • auth.getConfig() — asks the platform which OAuth providers the owner has connected and whether a CAPTCHA is switched on, so a login page renders from server-side settings instead of hardcoding them.
  • auth.startOAuth(provider) — begins an OAuth sign-in. A full navigation, not a fetch.
  • renderTurnstile(element, siteKey) — draws the challenge widget and resolves with its token. Keys are Chattee's, so there is nothing to register.
  • turnstileToken accepted by signUp, signIn, requestMagicLink and requestPasswordReset. Optional; only needed when getConfig() reports a site key.

Requires a Chattee backend from the same release or later — getConfig() returns 404 against an older one, and the OAuth and CAPTCHA endpoints will not exist.

1.0.0

First release: password sign-up and sign-in, magic links, password reset, email verification, sessions verified by a local read, requireAuth() / requireRole(), and invitations.


Licence

MIT