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

@aiquants/auth-react-router

v0.18.0

Published

React Router v7 / v8 auth adapter for @aiquants/auth-core: Google OAuth strategy factory (remix-auth), cookie session storage with DI test backdoor, a loader guard that authenticates at most once per request (token refresh + photo cache), auth route handl

Readme

@aiquants/auth-react-router

React Router v7 auth adapter for @aiquants/auth-core: Google OAuth (remix-auth v4 + a vendored GoogleStrategy over remix-auth-oauth2) authenticator factory, cookie session storage, per-request loader guard with token refresh + photo cache, auth route handlers, and a <GoogleForm> login button. All app couplings (env, backend HTTP, DB photo lookup, warmup, test backdoor) are DI ports. The Google strategy (formerly @coji/remix-auth-google, 68 lines) is vendored in src/server/google-strategy-impl.ts, so the package has no @coji/* dependency.

Install

pnpm add @aiquants/auth-core @aiquants/auth-react-router

Peers: react, react-router@^7, remix-auth@^4, remix-auth-oauth2@^3, react-icons.

User admin shell: the self-permission badge

The admin shell (createUserAdminApp) renders a "your permissions" badge in its header. Two optional ports feed it, and both are fail-closed by design:

createUserAdminApp({
  store, guards,
  // resolves to a map of { canRead, canWrite, canDelete } per resource key.
  // Any authz view carrying those three booleans satisfies it structurally — no import from an authz package.
  getMyPermissions: (request) => myAuthz.getMyPermissions(request, { resourceKeys: ["authz_admin"] }),
  // display names for those keys; unlisted keys render as the raw key
  resourceLabels: { authz_admin: "ユーザー管理 UI" },
})
  • Query the resource your guards actually check. Asking about a different key makes the badge disagree with the guard — the user is admitted but told they have no permission.
  • If the port is absent, rejects, or returns no entries, the badge renders an explicit "unknown" state (data-testid="user-admin-permission-unavailable") and the failure is logged. It never renders a permissive value: a badge that claims "editable" when the answer is unknown is worse than no badge at all.

Wiring (single composition point — app/services/auth/config.server.ts)

import { emailDomainAllowlist, parseAllowlistCsv } from "@aiquants/auth-core"
import { createAuthServer } from "@aiquants/auth-react-router/server"

export const authServer = createAuthServer({
    google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, redirectURI: env.GOOGLE_OAUTH_REDIRECT_URL },
    session: { secrets: [env.REMIX_SESSION_SECRET] },
    isEmailAllowed: emailDomainAllowlist(parseAllowlistCsv(env.ALLOWED_EMAILS), parseAllowlistCsv(env.ALLOWED_DOMAINS)),
    isUserActive: async (profile) => isAccountEnabled(profile),   // optional: deactivation revokes live sessions
    backendAuth: { verify: verifyBackend, signup: (p, t) => signupBackend(p, t) },      // FastAPI verify/signup
    getUserPhoto: async (openid) => (await getOpenids({ openid }))[0]?.picture ?? "",   // your identity store
    warmup: async () => { await Promise.all([ensurePrimaryConnection(), ensureSecondaryConnection()]) },
    mockUser: mockUserPort,   // test backdoor (E2E) — omit in production-only apps
})
export const { authenticator, sessionStorage, getSession, getSessionUser, saveSession, requireUser, requireAdminUser, commitSession, destroySession, authenticate, refreshedAccessToken, authenticateInLoader, loginLoader, loginAction, logoutLoader, logout, logoutAndRedirect, googleCallbackLoader } = authServer

Client:

import { GoogleForm } from "@aiquants/auth-react-router/client"
<GoogleForm />   // <Form method="POST"> + _action="Sign In with Google"

User administration surface (/admin)

A splat-mounted admin UI for users, groups, group members, and the sign-in allowlist — the identity-side counterpart to @aiquants/authz-react-router.

import { createUserAdminApp, jaUserAdminLabels } from "@aiquants/auth-react-router/admin"

export const userAdminApp = createUserAdminApp({
    store: myUserAdminStore,          // you implement UserAdminStore against your DB
    labels: jaUserAdminLabels,        // optional; package default is English
    guards: {                         // REQUIRED — see below
        requireAccess: async (request, { action }) => {
            await requireUser(request)
            await requirePermission(request, { resourceKey: "user_admin", action })
        },
    },
})
import { AuthUserAdminAppView } from "@aiquants/auth-react-router/admin"
export const loader = userAdminApp.loader
export const action = userAdminApp.action
// mounted as the splat route "/users/*"
export default () => <AuthUserAdminAppView basePath="/users" />
  • basePath is required (AuthUserAdminAppView / UserAdminShell): the absolute path the console is mounted at, without a trailing slash ("/users" for a /users/* route). The five tabs and the per-row "members" links of the groups tab are built from it. There is no default: the host decides where the console lives, and a built-in default would keep drawing links to that default after the mount moves — the routes still resolve and the build still passes, only the links point at a place that is not served.

  • guards is mandatory — this surface lists every user and mutates group membership, so an unguarded mount is an account-enumeration and privilege-escalation path. Allow by returning, deny by throwing (Response / redirect / Error); the return value is void by contract, so a predicate-style guard that returns false cannot silently fail open.

  • Per-operation authorization: the guard receives the CRUD verb each intent actually performs (read / create / update / delete), so a principal holding only update cannot delete. Membership add/remove map to create/delete because they add and remove rows.

  • Unknown intents reach neither the guard nor the store (the intent table is a Map, so prototype keys such as constructor do not resolve).

  • Store errors (e.g. "cannot remove the last administrator") are returned as { ok: false, error } and rendered by the views; guard rejections propagate untouched.

  • Labels default to English (defaultUserAdminLabels); inject jaUserAdminLabels or a partial override via resolveUserAdminLabels.

Localization (labels.locale vs labels.formatLocale)

Every string this package draws lives in UserAdminLabels, and a host picks the language by injecting a whole catalog (defaultUserAdminLabels for English, the package default, or jaUserAdminLabels) or a partial override. Two keys carry language, and they are different axes:

| Key | Type | defaultUserAdminLabels | jaUserAdminLabels | Drives | | --- | --- | --- | --- | --- | | locale | UserAdminLocale ("en" | "ja") | "en" | "ja" | UI chrome language of both upstream-group pickers (SelectBox locale) | | formatLocale | BCP 47 string | "en-US" | "ja-JP" | Intl.DateTimeFormat (with timeZone) and the user-list Intl.Collator |

  • locale is forwarded to both @aiquants/select-box pickers (the create-group dialog and the directory link form). The picker strings this package owns (directoryView.pickerNoOptions / pickerClear / pickerToggle / pickerSelected) are passed as the picker's labels, the placeholder is the per-instance placeholder prop, and every other key comes from the select-box catalog of locale (the values below are those of defaultUserAdminLabels / jaUserAdminLabels and of select-box 0.11.0):

    | select-box key | Source | "en" | "ja" | | --- | --- | --- | --- | | noOptions | directoryView.pickerNoOptions | No matching group | 一致するグループがありません | | clear | directoryView.pickerClear | Clear the selection | 選択を解除 | | toggle | directoryView.pickerToggle | Show the group list | グループ一覧を開く | | selection | directoryView.pickerSelected ({value} = the picked option's label) | Selected: {value} | 選択中: {value} | | status | select-box catalog (live-region announcement; see below) | 2 options / 3 options, 2 available | 候補 2 件 / 候補 3 件・選べるもの 2 件 | | placeholder | placeholder prop: groupsView.pickUpstreamPlaceholder (dialog) / directoryView.pickUpstreamPlaceholder (link form) | Search by name or address... | 名前かアドレスで検索... | | scrollUp | select-box catalog | Scroll up | 上へスクロール | | scrollDown | select-box catalog | Scroll down | 下へスクロール | | scrollLeft | select-box catalog | Scroll left | 左へスクロール | | scrollRight | select-box catalog | Scroll right | 右へスクロール | | scrollToTop | select-box catalog | Top | 先頭へ | | scrollToBottom | select-box catalog | Bottom | 末尾へ | | noItems | select-box catalog | No items | 項目がありません | | searching | select-box catalog (message of the open list while nothing matches yet and more rows may still come) | Searching... | 検索中... | | guess | select-box catalog (accessible description of a suggested row, one the picker lists as a guess rather than a match) | suggestion | 推測 | | removeTag | select-box catalog (multi-select chips; both pickers are single-select) | Remove {label} | 「{label}」を削除 | | resizeHandle | select-box catalog (tooltip of the option list's resize handle) | Resize handle | サイズ変更ハンドル |

    To change a catalog-sourced string, there is no per-key override here: pick the locale whose catalog you want.

    The live-region announcement (status) is not overridden, so select-box owns its whole policy in one place: silent while the popup is closed, while rows may still come and while the query is being typed; pickerNoOptions (the resolved noOptions) when no row is listed; otherwise the listed count alone when every row is selectable, or the count followed by how many are selectable when some are not (already-linked upstream groups are listed but disabled): the available rows for a blank query ("3 options, 2 available" / 「候補 3 件・選べるもの 2 件」), the available matches for a typed one ("3 options, 2 available matches" / 「候補 3 件・選べる一致 2 件」). The examples in the table are the blank-query phrases.

  • resolveUserAdminLabels (run once when createUserAdminApp is built) validates locale: an omitted value keeps the English default "en"; "en" / "ja" pass; anything else ("ja-JP", "EN", "", null, ...) throws RangeError naming labels.formatLocale, so a mistake stops the app at startup instead of crashing the picker on first render. There is no language negotiation — map navigator.language or a user setting to "en" / "ja" yourself.

  • formatLocale is not validated: an unreadable tag degrades (dates fall back to the raw ISO string, sorting to the runtime collator) instead of throwing, because formatting must never take the screen down.

Migrating from 0.15.x (breaking changes in 0.16.0)

  1. basePath is now required on AuthUserAdminAppView / UserAdminShell (see above). Pass the absolute mount path, without a trailing slash: <AuthUserAdminAppView basePath="/users" />. Omitting it fails type-checking (TS2741); an untyped caller would draw links such as undefined/groups.
  2. labels.locale changed meaning: the old labels.locale (a BCP 47 tag for dates and sorting) is now labels.formatLocale — no alias. The new labels.locale (type UserAdminLocale = "en" | "ja") is the UI language of the pickers, validated at startup.
    • Hosts that inject jaUserAdminLabels whole, or spread it ({ ...jaUserAdminLabels, heading: "" }), need no change: it carries locale: "ja" and formatLocale: "ja-JP".
    • A host that overrode locale: "ja-JP" must write formatLocale: "ja-JP", and add locale: "ja" if it relies on Japanese picker chrome (without it the pickers' catalog-sourced strings, such as the scroll-arrow names, are English).
    • A host that builds a full UserAdminLabels literal must add both keys.
  3. @aiquants/select-box texts is gone: the pickers take select-box 0.10.0 locale / labels (SelectBoxLabelOverrides) instead of texts / SelectBoxTexts. This is internal to the views — hosts that only render AuthUserAdminAppView do nothing — but a host must install @aiquants/select-box >= 0.10.0 and @aiquants/virtualscroll >= 3.7.0 (the new peer floors).

Upstream group catalog (optional port)

The groups and directory tabs let the operator pick an upstream group instead of spelling its address. Wire the optional directoryCatalog port to enable it; omit it and both surfaces fall back to typing the address by hand.

createUserAdminApp({
    store: myUserAdminStore,
    guards: { ... },
    // `observedAt` is required. It is `null` only when the upstream has NEVER been observed —
    // which is not the same as "observed, and the answer was empty".
    directoryCatalog: {
        listGroups: async (request) => {
            const { groups, observedAt } = await myCatalogReader.read(request)
            return { groups, observedAt }
        },
    },
})

The list is usually a copy written by a separate process that holds the upstream credential, not a live read. That is why the port must report when the upstream was observed: a list with no timestamp reads as "the directory right now", and the operator picks a group that no longer exists. observedAt: null is rendered as unavailable / neverObserved rather than as an empty list, so a deployment whose synchronization has never run is never mistaken for a domain with no groups.

  • The catalog is a convenience read, never a gate. A failure is reported to the view as { kind: "unavailable", reason } — the tab still opens, and manual entry stays available. It is fetched only for the two segments that render a picker, and only for the intents that create a link.
  • reason is a classification, not the upstream's text (denied / rateLimited / upstreamError / neverObserved / unknown; the first three are derived from a numeric status when the thrown value carries one, neverObserved from a null observation time). The original error goes to console.error only: an upstream message can carry the service-account address, the impersonated subject, internal URLs, or a credential fragment, and holding read access to this page is not a reason to be shown any of them.
  • The same rule governs store errors. An exception's own text reaches the operator only when the store declares it operator-facing — by throwing UserAdminOperatorError, or by setting operatorFacing: true on the error (for hosts that cannot extend this package's class). Everything else is reported as a generic sentence and logged: a driver exception carries the schema, table, constraint and the offending values.
  • The provider is taken from getDirectoryStatus(), never from the form, and the link intents are refused outright when the directory is not enabled. The absence of a control in the UI is not a constraint.
  • observedAt and isStale are decided on the server. Judging staleness in the browser lets a client whose clock runs behind suppress the warning, and desynchronizes server and client rendering.
  • A host that overrides labels wholesale must supply groupsView.catalogFailures (five keys) alongside the rest; resolveUserAdminLabels fills anything omitted from a partial override.
  • Listing the catalog requires the same verbs as creating a link (update + create + delete), not read. It enumerates another organization's department structure and headcounts; a principal who cannot link has no use for it. A denial is turned into "no picker", never into a failed page.
  • The action re-checks what the picker offered. A key the catalog does not list is refused server-side (case and surrounding whitespace are treated as equal), because the picker's disabled rows are a UI promise, not a constraint. When no catalog is wired — or the upstream is unreachable — the check is skipped rather than turned into a refusal: refusing there would make linking impossible in exactly the deployments that must type the address by hand.
  • An upstream group already linked to another local group is refused, so its membership cannot be projected onto two groups (double-counted members, both sets of roles granted).
  • createLinkedGroup creates the group and links it in one intent, and therefore requires the union of the verbs both halves need (create + update + delete). Splitting it in two would leave a state where only one half succeeded.

Sync requests: queued on link, followed through on screen

The console never reaches the upstream itself. "Sync now" — and, since 0.15.0, every link creation — queues a request for the synchronization job (store.requestDirectorySync(groupId, actor)), and the job writes the result back to the database a few seconds later.

  • Both link intents return { ok: true, syncRequest } with "queued", "already-pending", or "not-queued". The last one means the link succeeded but the request could not be queued; it is reported as such (and logged) rather than thrown, because throwing would report a failure for a link that now exists. The request is scoped to the group, not the tenant, so linking one group does not re-sweep every link.
  • While directoryStatus.pendingRequestCount > 0, the shell re-validates the route data every intervalMs and stops after maxMs if the count never changes (a deployment with no job consuming the queue must not be polled forever from every open tab). Configure it with the optional pendingSyncPolling prop of AuthUserAdminAppView / UserAdminShell; the default is DEFAULT_PENDING_SYNC_POLLING (3 s, up to 5 min). Nothing here adds an upstream call: the re-validation reads the rows the job wrote.

Styling: this package has no hand-written component CSS (the GoogleForm classes are plain Tailwind utilities), so it ships no components-only artifact — only a standalone build. Two consumption modes, never mixed:

  • Tailwind v4 host — add @source "../node_modules/@aiquants/auth-react-router/src/**/*.{ts,tsx}"; (monorepo: ../../../../packages/auth-react-router/src/**/*.{ts,tsx}) so the GoogleForm classes are generated in the host's own canonical build; src ships in the published package.

  • Non-Tailwind host — import the single self-contained standalone build (pnpm run build:css → dist/styles/auth-react-router.standalone.css):

    @import "@aiquants/auth-react-router/styles/auth-react-router.standalone.css";

Never mix the two: importing the standalone utility CSS next to a host Tailwind build duplicates same-named utilities, and base/variant cascade winners flip versus the single-build canonical order.

The admin surface renders @aiquants/select-box, whose own stylesheet is not part of either artifact above. Import it in the host, or the group picker's dropdown renders unstyled:

@import "@aiquants/select-box/styles/select-box.css";

That is the components-only build. Follow select-box's own README ("CSS Setup") for the rest of your build: a Tailwind v4 host imports it (and the @aiquants/virtualscroll peer build) in layer(components) and points @source at node_modules/@aiquants/select-box/dist (the published package ships no src); a host without Tailwind imports @aiquants/select-box/styles/select-box.standalone.css instead.

The option rows of both pickers are drawn by this package: each row shows the group name and address, a note (the local group already linked to it, which makes the row unpickable, or else its member count when known), and the part of the label the typed query matched. The match paint is the picker's own (RenderOptionContext.highlight: the query compiled once under the picker's normalization and bound to the row's match category), so a row the picker lists as a guess is never painted as an exact match; the runs carry data-aqar-match="exact" or "fuzzy" and are paint-only (background and text colour, no bold), so a label keeps its width while you type.

@aiquants/fuzzy-search is a transitive peer (the picker's search index, reached only through select-box) and is declared here as well, so a host that installs this package is told what the picker needs rather than discovering it as a runtime resolution failure. @aiquants/virtualscroll is both: select-box renders its option list with it, and this package imports it directly — src/admin/labels.ts resolves the UI language with resolveVirtualScrollLocale, so dist/admin.mjs carries its specifier. The pickers use the locale / labels props of @aiquants/select-box and its 0.11 render and typing contract (RenderOptionContext.highlight, and value / onChange typed by the option type), plus the locale resolution of @aiquants/virtualscroll 3.7.0, so select-box 0.11.0 and virtualscroll 3.7.0 are the peer floors (the published manifest carries ^0.11.0, which a 0.10 install does not satisfy).

All three are required peers, not optional. src/admin/views.tsx imports @aiquants/select-box and src/admin/labels.ts imports @aiquants/virtualscroll at the top level, and ./admin re-exports both modules, so the specifiers survive into dist/admin.mjs: a host without them does not get a degraded picker, it gets ERR_MODULE_NOT_FOUND for the whole admin surface — including the groups and allowlist tabs, which have nothing to do with the picker. Marking them optional would suppress the install warning that is the only thing standing between a consumer and that failure. Note also that @aiquants/select-box pins React 19, which is stricter than this package's own react: ">=18"; a host that mounts /admin must satisfy the stricter one.

Behavior Contract

  • Session cookie byte compatibility: __session (httpOnly/lax/30d), key "user", value = { id, displayName, name, emails, accessToken, refreshToken?, expirationDateMs?, provider, role? } — never photo/_json/photos. Changing this invalidates all live 30-day sessions.
  • Verify order: Google profile check → backendAuth.verify → allowlist → backendAuth.signup (every login). Failures redirect to <loginPath>?error=<encoded error message> (messages overridable via messages).
  • Token refresh: expiry check → Google oauth2/v4/token rotate → session update; invalid_grant destroys the session and redirects to login with Set-Cookie.
  • Loader guard: authenticateInLoader(request, { failureRedirect }) returns { user, cookie? } with user required when failureRedirect is a string (any authentication failure throws a redirect), and { user?, cookie? } when it is null (no-redirect mode). Passing "" throws a TypeError before any work — it is a mistake, not a request for the default.
  • One authentication per request: the guard performs its work at most once per Request instance and shares a mode-neutral result, so a root loader (null) and a leaf loader ("/auth/login") in the same React Router data pass share a single pass — one warmup, one token refresh, one Set-Cookie. The failure policy is applied per caller on top of the shared result, so it must never be part of the cache key.
  • Infrastructure failures are never laundered into logouts: anything that is not an authentication failure (an unexpected error inside the auth step, a getUserPhoto port outage, a saveSession failure) propagates unchanged in both modes. The session is not destroyed and no redirect is produced.
  • Photo lookups are cached with in-flight dedup per factory instance.

DI Ports Absorbing Per-App Differences

| Port | App A | App B | | --- | --- | --- | | google.scopes | default | + directory.readonly etc. | | mockUser | cookie parse + name map | fixed id | | warmup | 2 pools | 1 pool | | backendAuth | primary-api | secondary-api | | isUserActive | DB lookup + allowlist re-check | omitted (always active) |

isUserActive is evaluated at login and on every session resolution, so disabling an account revokes already-issued session cookies. Return false only for a known-disabled account — returning false for an unknown identity would block first-time sign-up.

⚠️ isEmailAllowed may return a Promise (to consult a database as well as env vars). Callers must await it: a Promise is always truthy, so a missing await makes the allowlist fail open, and the type checker cannot catch it.

Intentional Changes vs Original (§8, approved)

  1. No import-time env reads/throws — all validation happens in createAuthServer.
  2. Allowlist matching is trim+lowercase (unified with the Python side via @aiquants/auth-core).
  3. Dead exports (refreshBackend, authenticateBackend) are not ported — the backend port only needs verify/signup.

MIT