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

@clocklobster/cognito-client

v1.1.3

Published

Generic, dependency-injected browser AWS Cognito client — sign-up, sign-in, session restore/refresh, NEW_PASSWORD_REQUIRED challenge, forgot/reset password

Downloads

1,054

Readme

cognito-client

License: MIT TypeScript Tests

A generic, dependency-injected browser client for AWS Cognito user pools — sign-up, confirmation, sign-in, session restore/refresh, sign-out, forgot/reset password, and the NEW_PASSWORD_REQUIRED challenge. No product coupling, no hardcoded routes, no default targets — every dependency is injected.

Why this exists: AWS Amplify Auth is heavyweight and opinionated. The raw amazon-cognito-identity-js SDK is callback-based and untyped. This client wraps the SDK into a clean, typed, Promise-based surface with every product concern (storage, error messages, navigation, pool config) injected — so you own the UX and policy, and the client owns the Cognito mechanics.


Table of contents


Overview

cognito-client provides a product-neutral Cognito lifecycle for browser applications. It wraps the amazon-cognito-identity-js SDK (loaded as a browser global or injected as a mock) behind a typed, Promise-based interface:

  • Sign-up + email confirmation
  • Sign-in with NEW_PASSWORD_REQUIRED challenge support
  • Session restore (survives page reload / post-login redirect)
  • Session refresh (uses cached refresh token)
  • Forgot password + confirm new password
  • Sign-out (clears tokens + SDK session)
  • Redirect to login with ?returnTo= preservation

Every product concern is injected:

  • sdk — the amazon-cognito-identity-js namespace
  • userPoolId / clientId — pool configuration (plain strings or lazy suppliers)
  • storage — the Storage used for SDK persistence (bind sessionStorage so tokens never touch localStorage)
  • errorMapper — maps SDK errors to your product's error copy
  • navigate / getCurrentPath — navigation hooks for redirectToLogin

Features

  • Fully dependency-injected — no hardcoded pool IDs, no hardcoded routes, no hardcoded error messages. You own the product policy; the client owns the Cognito mechanics.
  • Promise-based — wraps the callback-based SDK into clean async/await
  • TypeScript-native — full types for every method, option, and SDK interface
  • NEW_PASSWORD_REQUIRED challenge — surfaced as a SignInResult.challenge, not an error
  • Session persistence — tokens survive the post-login redirect via the injected Storage
  • Lazy pool config — userPoolId / clientId can be functions resolved at first use (for apps that load config at runtime)
  • Token safety — runtime tokens live in memory only; the SDK session (refresh token) lives in the injected Storage (use sessionStorage so it clears on tab close)
  • Product-neutrality tested — a test asserts the core source contains no product roles, routes, or copy
  • Zero runtime dependencies — only dev dependencies (TypeScript, Vitest, fast-check, jsdom, Stryker)

Install

npm install @clocklobster/cognito-client
# or
pnpm add @clocklobster/cognito-client

Peer requirement

This client wraps amazon-cognito-identity-js. Install it in your app and pass the SDK namespace to the client constructor:

npm install amazon-cognito-identity-js

Requirements

  • Browser environment (uses Storage, window navigation)
  • TypeScript >= 5 (for type consumers; ships .d.ts files)
  • amazon-cognito-identity-js loaded as a browser global or importable module

Quick start

import { CognitoClient } from '@clocklobster/cognito-client';
// Load the SDK — as a browser global, or via import:
// import * as AmazonCognitoIdentity from 'amazon-cognito-identity-js';

const cognito = new CognitoClient({
  // Pool configuration (plain strings or lazy suppliers)
  userPoolId: 'us-east-1_XXXXXXXXX',
  clientId: 'your-app-client-id',

  // The amazon-cognito-identity-js namespace
  sdk: AmazonCognitoIdentity,

  // Storage for the SDK's session (refresh token).
  // Use sessionStorage so tokens never touch localStorage and clear on tab close.
  storage: sessionStorage,

  // Map SDK errors to your app's error messages
  errorMapper: (err) => {
    const msg = err instanceof Error ? err.message : String(err);
    if (msg.includes('NotAuthorizedException')) {
      return new Error('Incorrect email or password.');
    }
    if (msg.includes('UserNotFoundException')) {
      return new Error('No account found with that email.');
    }
    return new Error(msg || 'Authentication failed.');
  },

  // Navigation (used by redirectToLogin)
  navigate: (url) => (window.location.href = url),
  getCurrentPath: () => window.location.pathname + window.location.search,
});

// Sign up
await cognito.signUp('[email protected]', 'SecurePassword123!', [
  { Name: 'email', Value: '[email protected]' },
]);

// Confirm sign-up
await cognito.confirmSignUp('[email protected]', '123456');

// Sign in
const result = await cognito.signIn('[email protected]', 'SecurePassword123!');
if (result.challenge === 'NEW_PASSWORD_REQUIRED') {
  // Collect a new password from the user, then:
  const tokens = await cognito.completeNewPassword('NewSecurePassword456!', {
    // Optional: required attribute updates from the challenge
    name: 'Jane',
  });
  console.log('Signed in:', tokens.idToken);
} else {
  console.log('Signed in:', result.idToken);
}

// On page load (e.g. in your app bootstrap), restore the session:
const session = await cognito.getSession();
if (session) {
  console.log('Restored session for', session.user);
  console.log('ID token:', session.idToken);
}

// Sign out
cognito.signOut();

Configuration

CognitoClientOptions

| Option | Type | Required | Description | |---|---|---|---| | userPoolId | string \| (() => string) | yes | Cognito User Pool ID (e.g. us-east-1_XXXXX). Can be a lazy supplier resolved at first auth operation. | | clientId | string \| (() => string) | yes | Cognito App Client ID. Can be a lazy supplier. | | sdk | CognitoSdk | yes | The amazon-cognito-identity-js namespace (browser global or imported module). | | storage | Storage \| (() => Storage \| undefined) | yes | The Storage used for ALL SDK persistence. Bind sessionStorage so tokens never touch localStorage. Can be a lazy supplier. | | errorMapper | (err: unknown) => Error | yes | Maps SDK errors to your app's error messages. The SDK throws opaque errors; this is your chance to translate them. | | navigate | (url: string) => void | yes | Navigation function used by redirectToLogin. Typically (url) => window.location.href = url. | | getCurrentPath | () => string | no | Returns the current page path + query, used to build ?returnTo=. Defaults to empty string (no returnTo). |

Lazy suppliers

userPoolId, clientId, and storage accept either a plain value or a function. The function is resolved at first auth operation (not at construction), so apps that load config at runtime (e.g. from a fetched config endpoint) can supply a lazy supplier:

const cognito = new CognitoClient({
  userPoolId: () => appConfig.cognito.userPoolId,
  clientId: () => appConfig.cognito.clientId,
  // ...
});

API reference

CognitoClient

import { CognitoClient } from '@clocklobster/cognito-client';

const cognito = new CognitoClient(options);

Method-level companion with signatures, returns/throws, and the exported utilities (isTokenExpired(), assertSessionStorageOnly()): docs/api.md.


signUp(email, password, attributeList?)

Registers a new user in the Cognito user pool.

const result = await cognito.signUp('[email protected]', 'Password123!', [
  { Name: 'email', Value: '[email protected]' },
  { Name: 'phone_number', Value: '+14165551234' },
]);
// result: { userConfirmed: boolean, userSub: string }
  • attributeList — array of { Name, Value } Cognito attributes (defaults to [])
  • Returns { userConfirmed, userSub } — userConfirmed is false if email/SMS verification is required before sign-in

confirmSignUp(email, code)

Confirms a sign-up using the verification code sent to the user's email/SMS.

await cognito.confirmSignUp('[email protected]', '123456');

signIn(email, password)

Authenticates a user. Returns either tokens (success) or a NEW_PASSWORD_REQUIRED challenge that must be completed via completeNewPassword().

const result = await cognito.signIn('[email protected]', 'Password123!');

if (result.challenge === null) {
  // Success — tokens are available
  console.log(result.idToken, result.accessToken);
} else if (result.challenge === 'NEW_PASSWORD_REQUIRED') {
  // User must set a new permanent password
  console.log(result.userAttributes);
  console.log(result.requiredAttributes);
}

Returns: SignInResult — either:

  • { challenge: null, idToken: string, accessToken: string }, or
  • { challenge: 'NEW_PASSWORD_REQUIRED', userAttributes, requiredAttributes }

On success, the session is persisted to the injected Storage so it survives the post-login redirect.


completeNewPassword(newPassword, userAttributes?)

Completes a NEW_PASSWORD_REQUIRED challenge issued by signIn(). Throws if no challenge is in flight.

const tokens = await cognito.completeNewPassword('NewPassword456!', {
  name: 'Jane Doe',  // optional required attribute updates
});
// tokens: { idToken, accessToken }
  • userAttributes — any required attribute updates from the challenge. The sub attribute is automatically scrubbed (Cognito rejects resending it).
  • On success, tokens are stored and the challenge state is cleared.
  • On failure, the challenge state is cleared and the user must sign in again.

getSession()

Restores a cached session from the injected Storage. Used on page load to check if the user is already authenticated.

const session = await cognito.getSession();
if (session) {
  console.log('User:', session.user);
  console.log('ID token:', session.idToken);
  console.log('Access token:', session.accessToken);
} else {
  // Not authenticated — redirect to login
  cognito.redirectToLogin('/login.html');
}

Returns: RestoredSession | null

  • On success: { idToken, accessToken, user }
  • On failure (stale/invalid session): signs out the SDK user, clears tokens, returns null
  • On synchronous SDK throw (no cached refresh token): clears tokens, returns null

ensureSession(loginUrl)

Canonical async page-load gate for protected pages. Unlike a sync token-presence check (which passes with a stale-but-cached token after credentials timeout, letting the page fetch and render private data before the 401 path discovers the dead session), this validates the session through the SDK first.

const session = await cognito.ensureSession('/login.html');
if (!session) return; // dead session — already redirected to login
// live session (refreshed via the stored refresh token when needed)
console.log('ID token:', session.idToken);

Returns: RestoredSession | null — never throws.

  • Live session: returned as { idToken, accessToken, user }, refreshing via the stored refresh token when the id token expired but the refresh token is still alive (seamless, no redirect).
  • Dead session: signs out the stale SDK state, redirects to loginUrl (with ?returnTo= preservation), returns null — before any API fetch fires.

refreshSession()

Refreshes the session using the cached refresh token. Safe to call even when the SDK's signInUserSession has not been loaded into memory yet (e.g. an API call races the page's own getSession() call).

const tokens = await cognito.refreshSession();
// tokens: { idToken, accessToken }

Returns: SessionTokens — { idToken, accessToken }

Throws: if there is no cached session or the refresh token is invalid.


forgotPassword(email)

Initiates the forgot-password flow. Cognito sends a verification code to the user's email/SMS. Resolves when the code has been sent (the inputVerificationCode callback fires).

await cognito.forgotPassword('[email protected]');
// Now prompt the user for the code + new password

confirmNewPassword(email, code, newPassword)

Completes the forgot-password flow by submitting the verification code and a new password.

await cognito.confirmNewPassword('[email protected]', '123456', 'NewPassword789!');

signOut()

Signs out the current user from the SDK and clears all token/challenge state.

cognito.signOut();

Clears:

  • idToken, accessToken, currentUser
  • pendingChallengeUser (any in-flight NEW_PASSWORD_REQUIRED challenge)

redirectToLogin(loginUrl)

Signs out any stale Cognito session, then navigates to loginUrl with the current page preserved as ?returnTo= so the login page can send the user back after sign-in.

cognito.redirectToLogin('/login.html');
// Navigates to: /login.html?returnTo=%2Fdashboard%3Ftab%3Dsettings
  • loginUrl — the login page URL (product policy — the adapter owns this)
  • Signs out first so the login form doesn't pick up a cached user whose tokens are dead
  • Uses getCurrentPath() (if provided) to build the ?returnTo= query parameter

Token accessors

| Method | Returns | Description | |---|---|---| | getUser() | string \| null | The current user's username (or null if not signed in) | | getIdToken() | string \| null | The current Cognito ID token JWT (or null) | | getAccessToken() | string \| null | The current Cognito access token JWT (or null) |

These return null when no session is active. They read from in-memory state set by signIn(), completeNewPassword(), getSession(), or refreshSession().


Types

// SignIn result — either tokens (success) or a challenge
type SignInResult =
  | { challenge: null; idToken: string; accessToken: string }
  | {
      challenge: 'NEW_PASSWORD_REQUIRED';
      userAttributes: Record<string, unknown>;
      requiredAttributes: Record<string, unknown>;
    };

// Session tokens
interface SessionTokens {
  idToken: string;
  accessToken: string;
}

// Restored session (from getSession)
interface RestoredSession extends SessionTokens {
  user: string;
}

// The SDK surface the client uses (inject amazon-cognito-identity-js)
interface CognitoSdk {
  CognitoUserPool: new (data: { UserPoolId: string; ClientId: string; Storage?: Storage }) => CognitoUserPoolLike;
  CognitoUser: new (data: { Username: string; Pool: CognitoUserPoolLike; Storage?: Storage }) => CognitoUserLike;
  AuthenticationDetails: new (data: { Username: string; Password: string }) => unknown;
}

// Client options
interface CognitoClientOptions {
  userPoolId: string | (() => string);
  clientId: string | (() => string);
  sdk: CognitoSdk;
  storage: Storage | (() => Storage | undefined);
  errorMapper: (err: unknown) => Error;
  navigate: (url: string) => void;
  getCurrentPath?: () => string;
}

Dependency injection

Every dependency is injected — the client has zero hardcoded values:

| Dependency | Purpose | Typical binding | |---|---|---| | sdk | The amazon-cognito-identity-js namespace | Browser global or import * as | | userPoolId | Cognito User Pool ID | String from config/env | | clientId | Cognito App Client ID | String from config/env | | storage | SDK session persistence | sessionStorage (never localStorage) | | errorMapper | SDK error → user-facing error | Your app's error message map | | navigate | Page navigation for redirectToLogin | (url) => window.location.href = url | | getCurrentPath | Current path for ?returnTo= | () => window.location.pathname + window.location.search |

This means:

  • No hardcoded pool IDs — different environments (dev/staging/prod) inject different pools
  • No hardcoded error messages — your app owns the UX copy
  • No hardcoded routes — your app owns the login URL and redirect logic
  • No hardcoded storage — bind sessionStorage for tab-scoped sessions, or a custom Storage implementation for testing

The NEW_PASSWORD_REQUIRED challenge

When a user signs in and Cognito requires a new permanent password (e.g. admin-created/ invited users in FORCE_CHANGE_PASSWORD state), signIn() does not throw an error. Instead, it returns a challenge result:

const result = await cognito.signIn(email, tempPassword);

if (result.challenge === 'NEW_PASSWORD_REQUIRED') {
  // The user is authenticated but must set a new password.
  // Show a "set new password" form, then:
  const tokens = await cognito.completeNewPassword(newPassword, {
    name: 'Jane',  // optional required attributes
  });
  // tokens.idToken / tokens.accessToken are now available
}

Key details:

  • The CognitoUser reference is held internally during the challenge — you don't need to re-authenticate to complete it
  • The sub attribute is automatically scrubbed from userAttributes (Cognito rejects resending it — it's read-only/server-managed)
  • On success, tokens are stored and the challenge state is cleared
  • On failure, the challenge state is cleared and the user must sign in again

Session persistence and the post-login redirect

Cognito's SDK persists the session (refresh token) to the injected Storage. This client binds sessionStorage by convention so:

  • Runtime tokens live in memory only — idToken / accessToken are never written to storage; they're held in the CognitoClient instance
  • The SDK session (refresh token) lives in sessionStorage — so it survives the post-login redirect but clears when the tab closes
  • On page load, call getSession() to restore the session from sessionStorage
// App bootstrap (every page load):
const session = await cognito.getSession();
if (session) {
  // User is authenticated — render the app
  initApp(session);
} else {
  // Not authenticated — redirect to login
  cognito.redirectToLogin('/login.html');
}

// Login page (after successful signIn):
const result = await cognito.signIn(email, password);
if (result.challenge === null) {
  // Validate ?returnTo= before navigating: same-origin path only (see
  // docs/plan-evidence.md validation rules — rejects absolute URLs,
  // protocol-relative URLs, backslash escapes, control chars, and
  // scheme/absolute URLs with ':' before any '/?#'; falls back to '/').
  function resolveReturnTo(raw: string | null): string {
    if (!raw) return '/';
    let v = raw;
    try {
      v = decodeURIComponent(raw);
    } catch {
      return '/';
    }
    if (!v.startsWith('/') || v.startsWith('//') || v.includes('\\')) return '/';
    if (/[\x00-\x1f\x7f]/.test(v)) return '/';
    const q = v.search(/[\/?#]/);
    const pre = q === -1 ? v : v.slice(0, q);
    if (pre.includes(':')) return '/';
    return raw;
  }
  window.location.href = resolveReturnTo(
    new URLSearchParams(window.location.search).get('returnTo'),
  );
}

Security model

  • Runtime tokens in memory only — idToken and accessToken are never written to Storage. They live in the CognitoClient instance and are cleared on sign-out or terminal failure.
  • SDK session in sessionStorage — the refresh token persists in the injected Storage (bind sessionStorage, not localStorage, so it clears on tab close).
  • Terminal failure clears state — a stale/invalid cached session triggers signOut()
    • clearTokens(), so no dead token state survives.
  • Sign-out is thorough — calls cognitoUser.signOut() on the SDK AND clears all in-memory token/challenge state.
  • redirectToLogin signs out first — so the login form doesn't pick up a cached user whose tokens are dead.
  • sub is scrubbed — completeNewPassword strips the sub attribute from userAttributes before sending (Cognito rejects resending it).

Consumer data-handling

  • What is stored where — runtime idToken / accessToken (and currentUser) live in memory only on the CognitoClient instance; they are never written to Storage, localStorage, cookies, URLs, or logs. Only the SDK session (refresh token) lives in the injected Storage so it survives the post-login redirect.
  • Tab-close clearing — bind the injected storage to sessionStorage, never localStorage, so the SDK session clears when the tab closes. Do not copy getIdToken() / getAccessToken() into storage, URLs, or telemetry yourself; re-read via the accessors or refreshSession() when expired.
  • errorMapper PII / user-enumeration caution — do not echo raw SDK messages to the UI. Distinguishing UserNotFoundException ("no account") from NotAuthorizedException ("wrong password") leaks account enumeration; prefer a single generic message (e.g. "Incorrect email or password.") unless your product explicitly accepts the enumeration trade-off.
  • HTTPS / CSP host-page expectations — serve the host page over HTTPS, set a Content-Security-Policy that disallows inline-script open redirects, and validate ?returnTo= with resolveReturnTo (same-origin path only) before assigning to window.location.href.

Testing

The suite uses Vitest with a jsdom environment and a mock SDK. 59 tests across 9 describe blocks:

| Describe block | Tests | Coverage | |---|---|---| | CognitoClient - dependency injection | 5 | Lazy pool config, lazy storage, SDK injection, error mapper | | CognitoClient - signIn / session | 10 | signIn success, NEW_PASSWORD_REQUIRED challenge, completeNewPassword, getSession, refreshSession, forgotPassword, confirmNewPassword | | CognitoClient - sign-out / navigation | 3 | signOut, redirectToLogin with ?returnTo=, stale session cleanup | | CognitoClient - ensureSession page-load gate | 4 | Live session, dead-session redirect, refresh path, never-throws contract | | CognitoClient - product neutrality | 2 | No product roles/routes on the prototype, no product terms in source | | CognitoClient - property tests | 23 | Invariants over generated inputs (tokens, scrubbing, lazy config, redirect, sign-up, errors) | | isTokenExpired - fail-closed JWT expiry probe | 2 | Expiry/skew boundaries, malformed tokens and missing exp | | CognitoClient - SDK error and edge-case behavior | 7 | Edge paths where mutants previously survived | | cognito-client storage guard | 3 | sessionStorage accepted, localStorage rejected, initPool fails closed |

npm test             # vitest run (jsdom, mock SDK — no real Cognito calls)

The product-neutrality test asserts that the CognitoClient source contains no product-specific terms (roles, routes, copy) — this guarantees the core stays generic as it evolves.


Development

# Install dependencies
pnpm install

# Typecheck
pnpm run typecheck    # tsc --noEmit

# Run tests (jsdom + mock SDK — no real Cognito)
pnpm test             # vitest run

# Build (emit to dist/)
pnpm run build        # tsc -p tsconfig.build.json

postinstall runs tsc -p tsconfig.build.json on every install, so dist/ is rebuilt automatically after pnpm install.

CI re-run notes

  • Dependency audit (blocking): corepack pnpm audit --audit-level=critical
  • Dependency audit (advisory, non-blocking): corepack pnpm audit --audit-level=high (continue-on-error: true in CI — re-run locally to triage new advisories)
  • Secret scan (fail-closed): run the Secret scan step of the verify job in .github/workflows/ci.yml (canonical pattern list)

Requirements

  • Node.js 22 (see .nvmrc)
  • pnpm 11 via corepack (or npm/yarn — the package has no runtime dependencies)
  • TypeScript >= 5

Project layout

cognito-client/
├── src/
│   └── index.ts      # CognitoClient + all types (single file, 579 lines)
├── test/
│   ├── cognito-client.test.ts          # 33 unit tests
│   ├── cognito-client.property.test.ts # 23 property tests
│   └── storage-guard.test.ts           # 3 storage-guard tests
├── examples/
│   ├── quickstart.ts # offline consumer wiring (injected SDK stub, allowlisted returnTo)
│   └── session-lifecycle.ts # offline full session lifecycle (sign-in → refresh → sign-out)
├── docs/
│   ├── api.md          # method-level API companion
│   └── plan-evidence.md # canonical returnTo validation rules
├── package.json
├── tsconfig.json         # typechecks src + test + examples (noEmit)
├── tsconfig.build.json   # src-only build (never ships examples to dist)
├── vitest.config.ts
├── stryker.config.json
├── CHANGELOG.md
├── LICENSE
└── README.md

Comparison with Amplify

| | cognito-client | AWS Amplify Auth | |---|---|---| | Dependencies | Zero runtime (you inject the SDK) | Heavyweight (~50 deps) | | Bundle size | ~4KB (your code only) | ~100KB+ | | Error messages | You own them (errorMapper) | Amplify's defaults | | Routes | You own them (navigate) | Amplify's hosted UI / config | | Storage | You choose (sessionStorage recommended) | Amplify's localStorage default | | Pool config | String or lazy supplier | Static config at init | | NEW_PASSWORD_REQUIRED | First-class challenge result | Handled internally | | Product coupling | None (tested) | Amplify ecosystem assumptions | | TypeScript | Full types, strict | Full types |

When to use cognito-client: you want a thin, typed, dependency-injected Cognito wrapper that you fully control. You own the UX, the error messages, the storage strategy, and the routing.

When to use Amplify: you want a batteries-included auth solution with hosted UI, social providers, MFA, and the full Amplify ecosystem — and you're OK with the bundle size and opinionated defaults.


Contributing

Pull requests are welcome. See CONTRIBUTING.md for setup, quality gates, and the release checklist. This project follows the Code of Conduct; report security issues per SECURITY.md.

Guidelines

  1. Add or update tests for any change (Vitest, jsdom, mock SDK).
  2. Ensure pnpm run typecheck and pnpm test pass.
  3. Do not commit secrets, .env files, or dist/ output.
  4. Follow the existing code style (strict TypeScript, no any, dependency injection).
  5. Keep the core product-neutral — the product-neutrality test must stay green. No product roles, routes, or copy in src/index.ts.

License

MIT © Victor Salmon