@molecule/api-resource-user

API resource · auth · API (Node) · v1.7.0 · Apache-2.0

User registration, login, JWT sessions

npm install @molecule/api-resource-user

npm · Source on GitHub

How it works

@molecule/api-resource-user is an API resource: the routes, validation and storage for auth, built on the database and auth cores so it runs on whichever providers your app has bonded.

// Extend safely: a display field in Props, a secret in SecretProps.
//   propsSchema:       { …, timezone: z.string().optional() }           // safe → client
//   secretPropsSchema: { …, passwordResetToken: z.string().optional() } // server-only, usersSecrets

// A custom handler returns SAFE props — never the secrets row.
router.get('/me/timezone', async (req, res) => {
  const userId = getUserId(res)
  if (!userId) return res.status(401).json({ error: 'Authentication required.' })
  const user = await findById('users', userId) // the users table holds Props only
  res.json({ timezone: user?.timezone }) // never spread a secrets-table row here
})

Works with: @molecule/api-bond, @molecule/api-config, @molecule/api-database, @molecule/api-i18n, @molecule/api-jwt, @molecule/api-locales-user, @molecule/api-locales-user-payments, @molecule/api-password, @molecule/api-rate-limit, @molecule/api-resource, @molecule/api-resource-device, @molecule/api-secrets

Secrets: JWT_PRIVATE_KEY, JWT_PUBLIC_KEY

Live demos

Running apps built with Molecule that install this package — open the app or study the template.

Reference

The user resource types, schema, and definition.

This package owns TWO tables, and the key that joins them is the user's own id.

  • users — Props: id, username, name, email, emailVerified, avatar, bio, twoFactorEnabled, the oauth* and plan* columns. There is no password column here.
  • usersSecrets — SecretProps: passwordHash, passwordResetToken (and passwordResetTokenAt), twoFactorSecret / pendingTwoFactorSecret, lastTwoFactorTimeStep.

Table setup: src/__setup__/users.sql + src/__setup__/usersSecrets.sql; an mlcl-scaffolded API replays __setup__/*.sql on migrate.

usersSecrets.id IS the user's id — the value of users.id, never a fresh uuid. The DDL declares it UNIQUE PRIMARY KEY with no foreign key, so nothing enforces it: a row written under another id is never found again — login answers "invalid credentials" while GET /api/users/me still answers 200. A hand-written signup writes both rows: const id = uuid(); await create('users', { ...props, id }); await create('usersSecrets', { id, passwordHash: await hash(password) }).

Prefer not to hand-write that — handlers.create (signup: both rows, hash via @molecule/api-password, session device), handlers.logIn (reads usersSecrets by the user's id), handlers.updatePassword, handlers.forgotPassword / handlers.resetPassword. To touch one secret yourself use the datastore on the same key — findById('usersSecrets', userId) (or ${tableName}Secrets for a custom tableName) — never SQL.

Issuing a session yourself (the sequence is device, then session — two calls, and the first RETURNS the id): the middleware accepts both Authorization: Bearer <jwt> and Authorization: Token <jwt> (RealWorld's scheme), but only for a token whose deviceId names an ACTIVE row in devices — a JWT you sign() with an invented deviceId is answered 401 on every route.

import { get } from '@molecule/api-bond'
import { authorization } from '@molecule/api-resource-user'
import type { DeviceService } from '@molecule/api-resource-device'

const deviceId = await get<DeviceService>('device')?.createOrUpdate(userId, deviceName)
const token = authorization.set(req, res, { userId, deviceId })

get<DeviceService>('device') is the typed accessor (never hand-type a get<{ createOrUpdate(): … }>('device') generic: a wrong one still compiles and leaves the field you invented — device.id — undefined), and createOrUpdate RESOLVES TO THE DEVICE ID STRING, not to a row. set() throws when deviceId is missing rather than mint a token every later request answers 401.

RealWorld-style token API (Authorization: Token <jwt>)? CONFIGURATION on this same auth path — never a credentials table of your own (usersSecrets.passwordHash is the password store). Set AUTH_SCHEME=Token (or verifyMiddleware({ scheme: 'Token' })): every route accepts it, logIn/create return accessToken, GET /users/me echoes it, and a password change revokes every OTHER device but keeps the caller's own session.

handlers.* return { statusCode, headers?, body } and never write to res — not through res.json, not through res.send, not through res.end. The mounted routes apply that object for you (createRequestHandler does res.set(headers), res.status(statusCode), res.send(body)); calling a handler directly is only correct if you apply the result yourself:

const { statusCode, headers, body } = await handlers.logIn(resource)(req, res)
if (headers) res.set(headers)
res.status(statusCode).send(body)

Monkey-patching res.json to re-shape a handler's answer intercepts nothing the handler produces, so the request it was meant to answer is left unanswered and hangs.

Re-shaping a success answer (RealWorld's { user: { …, token } }, say)? Read the token from result.body.accessToken — it is in the BODY. The Authorization response header carries the same token prefixed with the mount's scheme (Bearer <token>), so it is not the raw value a client sends back. Type the result from the handler — do not cast body to unknown, which hides the success branch — and narrow on the body's own keys:

const result = await handlers.logIn(resource)(req, res)
if (!('accessToken' in result.body)) {
  // 400/403/500 `{ error, errorKey }`, or 206 `{ twoFactorRequired: true }` — send as built.
  res.status(result.statusCode).send(result.body)
  return
}
// `props` and `user` are the SAFE columns (secret columns stripped); accessToken is the token.
const { props, accessToken } = result.body
res.status(200).json({ user: { ...props, token: accessToken } })

Constructing a handler: schema is optional. Every factory accepts { name, tableName, schema? }. schema is a Zod object (anything exposing safeParse) and only handlers.create / handlers.logInOAuth use it — both default to this package's propsSchema. handlers.update validates with its own update schema; login and the read/write handlers never validate props. The smallest correct wiring, with every import spelled out:

import { createRequestHandler } from '@molecule/api-resource'
import { createResource, handlers } from '@molecule/api-resource-user'

// Omit `schema`: create() validates new users with this package's propsSchema.
const signUp = createRequestHandler(handlers.create({ name: 'User', tableName: 'users' }))
const signIn = createRequestHandler(handlers.logIn({ name: 'User', tableName: 'users' }))

// A constrained schema, when the app restricts oauthServer / planKey.
const users = createResource({ oauthServers: ['google', 'github'] as const })
const signUpConstrained = createRequestHandler(handlers.create(users))

schema: {} (or any object without safeParse) is NOT a schema: the first request answers 400 resource.error.unknownError with TypeError: schema.safeParse is not a function, and no row is written. Omit schema instead of stubbing it.

Exact responses of the sign-up and sign-in handlers (the body shapes the routes send; a handler returns { statusCode, body } and the mount applies it):

  • create (POST /api/users, flat body): 201 { props, accessToken, user } (user equals props, secret columns stripped); 400 user.error.usernameInvalid / user.error.bodyNestedUnderUser / user.error.passwordRequired / user.error.passwordTooLong / user.error.passwordTooShort / user.error.emailInvalid / user.error.badRequest, or resource.error.unknownError when props fail the schema; 403 user.error.usernameUnavailable / user.error.emailAlreadyRegistered; 500 user.error.failedToHashPassword / user.error.failedToCreateSession.
  • logIn (POST /api/users/log-in): 200 { props, accessToken, user }; 206 { twoFactorRequired: true }; 403 user.error.invalidCredentials / user.error.invalidTwoFactorToken; 500 user.error.failedToCreateSession / user.error.twoFactorVerificationUnavailable / user.error.loginFailed. Bad credentials are 403, not 401 or 422.

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Quick Start

// Extend safely: a display field in Props, a secret in SecretProps.
//   propsSchema:       { …, timezone: z.string().optional() }           // safe → client
//   secretPropsSchema: { …, passwordResetToken: z.string().optional() } // server-only, usersSecrets

// A custom handler returns SAFE props — never the secrets row.
router.get('/me/timezone', async (req, res) => {
  const userId = getUserId(res)
  if (!userId) return res.status(401).json({ error: 'Authentication required.' })
  const user = await findById('users', userId) // the users table holds Props only
  res.json({ timezone: user?.timezone }) // never spread a secrets-table row here
})

Type

resource

Installation

npm install @molecule/api-resource-user @molecule/api-bond @molecule/api-config @molecule/api-database @molecule/api-entitlements @molecule/api-i18n @molecule/api-jwt @molecule/api-locales-user @molecule/api-locales-user-payments @molecule/api-password @molecule/api-payments @molecule/api-push-notifications @molecule/api-rate-limit @molecule/api-resource @molecule/api-resource-device @molecule/api-secrets @molecule/api-two-factor uuid zod

API

Interfaces

UserRequestHandlerMap

Shape of the user request-handler map produced by createRequestHandlerMap. Names match the route definitions in routes.ts. Exported so helpers that accept the map (e.g. mountDefaultUserAuthRoutes, mountDefaultUserCrudRoutes) can type their parameter precisely instead of widening to Record<string, MoleculeRequestHandler>.

interface UserRequestHandlerMap {
  auth: MoleculeRequestHandler
  authSelf: MoleculeRequestHandler
  rateLimitAuth: MoleculeRequestHandler
  rateLimitTwoFactor: MoleculeRequestHandler
  create: MoleculeRequestHandler
  logIn: MoleculeRequestHandler
  oauthAuthorize: MoleculeRequestHandler
  logInOAuth: MoleculeRequestHandler
  logout: MoleculeRequestHandler
  read: MoleculeRequestHandler
  readSelf: MoleculeRequestHandler
  update: MoleculeRequestHandler
  del: MoleculeRequestHandler
  updatePassword: MoleculeRequestHandler
  forgotPassword: MoleculeRequestHandler
  resetPassword: MoleculeRequestHandler
  verifyTwoFactor: MoleculeRequestHandler
  updatePlan: MoleculeRequestHandler
  verifyPayment: MoleculeRequestHandler
  billingPortal: MoleculeRequestHandler
  handlePaymentNotification: MoleculeRequestHandler
  requireWebhookAuthenticity: MoleculeRequestHandler
}

Types

Props

User props type inferred from schema — the row shape of the users table, which holds no password.

type Props = z.infer<typeof propsSchema>

SecretProps

Secret props type — the row shape of the usersSecrets table, whose id is the user's id.

type SecretProps = z.infer<typeof secretPropsSchema>

CreateProps

Create Props type.

type CreateProps = z.infer<typeof createPropsSchema>

CreateSecretProps

Create Secret Props type.

type CreateSecretProps = z.infer<typeof createSecretPropsSchema>

CreateOAuthProps

Create O Auth Props type.

type CreateOAuthProps = z.infer<typeof createOAuthPropsSchema>

UpdateProps

Update Props type.

type UpdateProps = z.infer<typeof updatePropsSchema>

UpdatePasswordSecretProps

Update Password Secret Props type.

type UpdatePasswordSecretProps = z.infer<typeof updatePasswordSecretPropsSchema>

UpdatePlanProps

Update Plan Props type.

type UpdatePlanProps = z.infer<typeof updatePlanPropsSchema>

VerifyTwoFactorProps

Verify Two Factor Props type.

type VerifyTwoFactorProps = z.infer<typeof verifyTwoFactorPropsSchema>

VerifyTwoFactorSecretProps

Verify Two Factor Secret Props type.

type VerifyTwoFactorSecretProps = z.infer<typeof verifyTwoFactorSecretPropsSchema>

Session

User session data (userId, email, role, permissions, metadata) inferred from sessionSchema.

type Session = z.infer<typeof sessionSchema>

Functions

createRequestHandlerMap(createRequestHandler)

Creates the full request handler map for the User resource. Optional features (OAuth, payments) are conditionally included based on bonded providers.

Handler names match the route definitions in routes.ts.

function createRequestHandlerMap(
  createRequestHandler: (
    handler: Handler,
  ) => (req: MoleculeRequest, res: MoleculeResponse, next: MoleculeNextFunction) => Promise<void>,
): UserRequestHandlerMap
  • createRequestHandler — Factory from @molecule/api-resource that wraps handler configs into Express middleware.

Returns: A UserRequestHandlerMap of handler names to Express middleware.

createResource(options)

Creates a user resource definition with optional OAuth servers and plan keys.

function createResource(options?: {
  oauthServers?: OAuthServers
  planKeys?: PlanKeys
}): types.Resource<unknown>
  • options — Optional configuration.
  • options.oauthServers — Tuple of allowed OAuth server names (e.g. ['google', 'github']). Constrains the oauthServer schema field.
  • options.planKeys — Tuple of allowed plan key strings (e.g. ['free', 'pro']). Constrains the planKey schema field.

Returns: A Resource with name 'User', table 'users', and a Zod schema reflecting the options.

createSchema(options)

Creates a full schema for user props.

OAuth servers and plan keys can be constrained by passing them as options.

function createSchema(options?: { oauthServers?: OAuthServers; planKeys?: PlanKeys }): z.ZodObject<
  {
    id: z.ZodString
    createdAt: z.ZodString
    updatedAt: z.ZodString
    username: z.ZodOptional<z.ZodString>
    name: z.ZodOptional<z.ZodString>
    email: z.ZodOptional<z.ZodNullable<z.ZodString>>
    emailVerified: z.ZodOptional<z.ZodBoolean>
    avatar: z.ZodOptional<z.ZodNullable<z.ZodString>>
    bio: z.ZodOptional<z.ZodNullable<z.ZodString>>
    twoFactorEnabled: z.ZodOptional<z.ZodBoolean>
    oauthServer:
      | z.ZodOptional<z.ZodString>
      | z.ZodOptional<
          z.ZodEnum<{
            [k in keyof { [k in NonNullable<OAuthServers>[number]]: k }]: {
              [k in NonNullable<OAuthServers>[number]]: k
            }[k]
          }>
        >
    oauthId: z.ZodOptional<z.ZodString>
    oauthData: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>
    planKey:
      | z.ZodOptional<z.ZodString>
      | z.ZodOptional<
          z.ZodEnum<{
            [k in keyof { [k in NonNullable<PlanKeys>[number]]: k }]: {
              [k in NonNullable<PlanKeys>[number]]: k
            }[k]
          }>
        >
    planExpiresAt: z.ZodOptional<z.ZodString>
    planAutoRenews: z.ZodOptional<z.ZodBoolean>
  },
  z.core.$strip
>
  • options — Optional configuration.
  • options.oauthServers — Tuple of allowed OAuth server names. Constrains oauthServer to a Zod enum.
  • options.planKeys — Tuple of allowed plan key strings. Constrains planKey to a Zod enum.

Returns: A Zod object schema extending basePropsSchema with user-specific fields (username, email, OAuth, plan).

Constants

i18nRegistered

The i18n registered.

const i18nRegistered: true

resource

Default user resource definition.

const resource: types.Resource<unknown>

routes

Route definitions for the User resource. Routes marked optional require additional packages to be installed.

Declarative route definitions used by the injection engine.

const routes: (
  | { method: 'post'; path: string; middlewares: string[]; handler: string; optional?: undefined }
  | { method: 'get'; path: string; middlewares: string[]; handler: string; optional: string }
  | { method: 'post'; path: string; middlewares: string[]; handler: string; optional: string }
  | { method: 'get'; path: string; middlewares: string[]; handler: string; optional?: undefined }
  | { method: 'patch'; path: string; middlewares: string[]; handler: string; optional?: undefined }
  | { method: 'delete'; path: string; middlewares: string[]; handler: string; optional?: undefined }
)[]

MAX_BIO_LENGTH

Maximum length (in characters) of a user's bio.

const MAX_BIO_LENGTH: 1000

MAX_AVATAR_LENGTH

Maximum length (in characters) of a user's avatar. Sized to permit a small inline data-URI (~256KB) without requiring an external upload/storage bond. Larger avatars must be hosted elsewhere and referenced by URL.

const MAX_AVATAR_LENGTH: number

propsSchema

Default schema for user props.

const propsSchema: z.ZodObject<
  {
    id: z.ZodString
    createdAt: z.ZodString
    updatedAt: z.ZodString
    username: z.ZodOptional<z.ZodString>
    name: z.ZodOptional<z.ZodString>
    email: z.ZodOptional<z.ZodNullable<z.ZodString>>
    emailVerified: z.ZodOptional<z.ZodBoolean>
    avatar: z.ZodOptional<z.ZodNullable<z.ZodString>>
    bio: z.ZodOptional<z.ZodNullable<z.ZodString>>
    twoFactorEnabled: z.ZodOptional<z.ZodBoolean>
    oauthServer: z.ZodOptional<z.ZodString> | z.ZodOptional<z.ZodEnum<{}>>
    oauthId: z.ZodOptional<z.ZodString>
    oauthData: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>
    planKey: z.ZodOptional<z.ZodString> | z.ZodOptional<z.ZodEnum<{}>>
    planExpiresAt: z.ZodOptional<z.ZodString>
    planAutoRenews: z.ZodOptional<z.ZodBoolean>
  },
  z.core.$strip
>

secretPropsSchema

Secret properties stored in the separate usersSecrets table, keyed by id = the user's id (the value of users.id, never a fresh uuid).

const secretPropsSchema: z.ZodObject<
  {
    id: z.ZodString
    passwordHash: z.ZodOptional<z.ZodString>
    passwordResetToken: z.ZodOptional<z.ZodString>
    passwordResetTokenAt: z.ZodOptional<z.ZodString>
    pendingTwoFactorSecret: z.ZodOptional<z.ZodString>
    twoFactorSecret: z.ZodOptional<z.ZodString>
    lastTwoFactorTimeStep: z.ZodOptional<z.ZodNumber>
  },
  z.core.$strip
>

createPropsSchema

Schema for creating a user via password.

const createPropsSchema: z.ZodObject<
  {
    username: z.ZodOptional<z.ZodString>
    name: z.ZodOptional<z.ZodString>
    email: z.ZodOptional<z.ZodNullable<z.ZodString>>
  },
  z.core.$strip
>

createSecretPropsSchema

Schema for creating secret props (password hash only).

const createSecretPropsSchema: z.ZodObject<
  { passwordHash: z.ZodOptional<z.ZodString> },
  z.core.$strip
>

createOAuthPropsSchema

Schema for creating a user via OAuth.

const createOAuthPropsSchema: z.ZodObject<
  {
    oauthServer: z.ZodOptional<z.ZodString> | z.ZodOptional<z.ZodEnum<{}>>
    oauthId: z.ZodOptional<z.ZodString>
    username: z.ZodOptional<z.ZodString>
    name: z.ZodOptional<z.ZodString>
    email: z.ZodOptional<z.ZodNullable<z.ZodString>>
    emailVerified: z.ZodOptional<z.ZodBoolean>
    avatar: z.ZodOptional<z.ZodNullable<z.ZodString>>
    bio: z.ZodOptional<z.ZodNullable<z.ZodString>>
    oauthData: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>
  },
  z.core.$strip
>

updatePropsSchema

Schema for updating a user (partial username, name, email, avatar, bio).

const updatePropsSchema: z.ZodObject<
  {
    username: z.ZodOptional<z.ZodOptional<z.ZodString>>
    name: z.ZodOptional<z.ZodOptional<z.ZodString>>
    email: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodString>>>
    avatar: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodString>>>
    bio: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodString>>>
  },
  z.core.$strip
>

updatePasswordSecretPropsSchema

Schema for updating password secret props (partial password hash).

const updatePasswordSecretPropsSchema: z.ZodObject<
  { passwordHash: z.ZodOptional<z.ZodOptional<z.ZodString>> },
  z.core.$strip
>

updatePlanPropsSchema

Schema for updating a user's plan (partial planKey, planExpiresAt, planAutoRenews).

const updatePlanPropsSchema: z.ZodObject<
  {
    planKey: z.ZodOptional<z.ZodOptional<z.ZodString> | z.ZodOptional<z.ZodEnum<{}>>>
    planExpiresAt: z.ZodOptional<z.ZodOptional<z.ZodString>>
    planAutoRenews: z.ZodOptional<z.ZodOptional<z.ZodBoolean>>
  },
  z.core.$strip
>

verifyTwoFactorPropsSchema

Schema for verifying two-factor authentication.

const verifyTwoFactorPropsSchema: z.ZodObject<
  { twoFactorEnabled: z.ZodOptional<z.ZodOptional<z.ZodBoolean>> },
  z.core.$strip
>

verifyTwoFactorSecretPropsSchema

Schema for two-factor secret props.

const verifyTwoFactorSecretPropsSchema: z.ZodObject<
  {
    twoFactorSecret: z.ZodOptional<z.ZodOptional<z.ZodString>>
    pendingTwoFactorSecret: z.ZodOptional<z.ZodOptional<z.ZodString>>
  },
  z.core.$strip
>

sessionSchema

Zod schema for JWT session payloads (userId, deviceId, optional OAuth fields).

const sessionSchema: z.ZodObject<
  {
    id: z.ZodOptional<z.ZodString>
    userId: z.ZodString
    deviceId: z.ZodString
    oauthServer: z.ZodOptional<z.ZodString>
    oauthId: z.ZodOptional<z.ZodString>
  },
  z.core.$strip
>

resourceUserSecretDefinitions

Secret definitions required by the user resource.

const resourceUserSecretDefinitions: SecretDefinition[]

Namespaces

z

Re-exports the zod module — z.<name> is exactly zod.<name>, documented by that package (installed at node_modules/zod).

authorization

Re-exported as a namespace — reach every member below through it (e.g. authorization.<name>).

authorization.getAuthCookieName(base)

Resolve the actual cookie name for an auth cookie.

In production the auth-bearing cookies get the __Host- prefix. Browsers reject any __Host--prefixed cookie that carries a Domain attribute (and require Secure + Path=/), so a sibling subdomain (e.g. a tenant preview at <token>.preview.molecule.dev) can no longer create a colliding .molecule.dev-scoped duplicate that shadows the platform's real session cookie — closing the cross-tenant cookie-tossing / session-fixation hole.

In dev/test the cookies are not Secure (plain HTTP origins), so a __Host- cookie would be rejected by the browser; use the plain name there.

Exported so consumers (e.g. molecule-dev's guest-session / OAuth-state code) set and read the exact same name the platform expects.

function getAuthCookieName(base: string): string
  • base — The base cookie name (e.g. 'token', 'sessionId').

Returns: The environment-appropriate cookie name.

authorization.getAuthCookieOptions()

Base cookie attributes shared by EVERY auth cookie this resource sets and clears — the single source of truth so a SET and its matching CLEAR always agree (a mismatched attribute leaves an undeletable cookie) and every auth cookie gets identical behaviour.

  • Cross-site preview (see isCrossSitePreview) → { secure: true, sameSite: 'none', partitioned: true, path: '/' }. The molecule.dev live preview renders the app in a CROSS-SITE iframe; browsers evaluate SameSite against the TOP-LEVEL site, so a Lax/Strict cookie is neither stored nor sent on ANY preview request — the httpOnly session cookie vanishes on every iframe reload and the user is logged out of their own app. SameSite=None makes it cross-site-sendable (which REQUIRES Secure — permitted over http on the potentially-trustworthy localhost / 127.0.0.1 origins), and Partitioned (CHIPS) keeps it working when third-party cookies are blocked (Brave/Chrome), which None alone does not. Applies whether the platform is dev or prod, because the preview app is cross-site either way.
  • Production, top-level → { secure: true, sameSite: 'lax', path: '/' }. The correct, CSRF-hardened default; pairs with the __Host- prefix (see getAuthCookieName). NEVER weaken it.
  • Development, top-level → { secure: false, sameSite: 'lax', path: '/' }. The original dev posture (plain http, not Secure) — used by the molecule.dev

(description truncated — read the full text in node_modules/@molecule/api-resource-user/dist/authorization.d.ts.)

function getAuthCookieOptions(): {
  secure: boolean
  sameSite: 'lax' | 'none'
  partitioned?: boolean
  path: string
}

Returns: The environment-appropriate base cookie options.

authorization.DEFAULT_AUTH_SCHEME

The Authorization scheme this resource uses when nothing configures another one — the scheme set writes into the response header.

const DEFAULT_AUTH_SCHEME: 'Bearer'
authorization.AUTH_SCHEME_LOCALS_KEY

res.locals key carrying the Authorization scheme THIS request's mount uses: 'Bearer' by default, 'Token' for a Django REST Framework / RealWorld-style API. verifyMiddleware records it from its scheme option (or the app's AUTH_SCHEME), and set reads it so a sign-in response labels its token with the SAME scheme the client will send back. A set() call outside a request (a handler invoked directly) falls back to AUTH_SCHEME, then to Bearer.

const AUTH_SCHEME_LOCALS_KEY: 'authScheme'
authorization.AUTH_TOKEN_LOCALS_KEY

res.locals key carrying the raw JWT that authenticated THIS request — written ONLY when the request sent it in the Authorization header, never when the session came from the httpOnly token cookie.

That asymmetry is deliberate: echoing a cookie-resolved token into a JSON body would hand JavaScript the exact credential the cookie exists to keep unreadable (an XSS exfiltration path). A header token is already in the client's hands, so GET /users/me may return it as accessToken to a token-only client that restores its session from that response.

const AUTH_TOKEN_LOCALS_KEY: 'authToken'
authorization.getConfiguredAuthScheme()

The app's configured Authorization scheme: AUTH_SCHEME from the environment, defaulting to DEFAULT_AUTH_SCHEME.

Read at call time (not module load) so a process — or a test — that changes the env sees the new value. A session-only app that never sets AUTH_SCHEME keeps emitting Bearer.

function getConfiguredAuthScheme(): string

Returns: A validated scheme name.

authorization.invalidateDeviceExistsCache(deviceId)

Evict a single device's positive entry from the device-exists cache so the next authorization check for that device re-consults the database.

Call this from any in-process revocation path that deletes a specific device row (e.g. a logout handler that knows the deviceId) so the revocation is effective immediately on this instance rather than after the cache TTL.

function invalidateDeviceExistsCache(deviceId: string): void
  • deviceId — The device id whose cached "exists" result should be dropped.
authorization.invalidateAllDeviceExistsCache()

Evict ALL positive entries from the device-exists cache.

Call this from in-process bulk revocation paths that delete every device for a user (password change/reset, account deletion, OAuth account-claim) where the affected device ids are not enumerated — the cache is keyed by deviceId, not userId, so a full clear is the correct way to guarantee none of the just-deleted devices keep authenticating on this instance. The normal-path per-request DB-cost reduction is preserved: the cache simply repopulates on subsequent requests; only these rare revocation events pay a brief re-check.

function invalidateAllDeviceExistsCache(): void

res.locals key carrying THIS request's cookie-session policy. verifyMiddleware writes it from its allowCookieSession option; set reads it. When the value is false the request arrived through a mount that authenticates by Authorization header only, so set() must not hand the client the token / sessionId / mol_auth cookies either: a cookie written here authenticates that same client on its NEXT request (an HTTP test runner's cookie jar, curl -b, a browser) through any mount that does accept one, which is how a suite step called "no auth" comes back 200.

Exported so a custom auth middleware can declare the same policy for the requests it handles, and so tests can assert the key rather than a string literal.

const ALLOW_COOKIE_SESSION_LOCALS_KEY: 'allowCookieSession'
authorization.AUTH_SESSION_SOURCE_LOCALS_KEY

res.locals key carrying HOW this request's session was resolved — 'header' when the Authorization header named the token, 'cookie' when only the token cookie did. verifyMiddleware writes it alongside res.locals.session.

A mount with allowCookieSession: false reads it to drop a session an EARLIER, laxer mount already resolved out of the cookie (the scaffold mounts verifyMiddleware twice, and the first one runs for every /api request). A session resolved from the header is never dropped: that client sent its credentials, which is exactly what a token-only API accepts.

const AUTH_SESSION_SOURCE_LOCALS_KEY: 'authSessionSource'
authorization.AuthSessionSource

How verifyMiddleware resolved a request's session. See AUTH_SESSION_SOURCE_LOCALS_KEY.

type AuthSessionSource = 'header' | 'cookie'
authorization.set(_req, res, session)

Set authorization headers and cookie for a session, and RETURN the signed token.

The sign-in sequence is device → session — one call each, and the device call gives you an ID:

import type { DeviceService } from '@molecule/api-resource-device'

const deviceId = await get<DeviceService>('device')?.createOrUpdate(userId, deviceName)
const token = set(req, res, { userId, deviceId })

createOrUpdate RESOLVES TO THE DEVICE ID — the id string, never a row — and to null when the row could not be written. get<DeviceService>('device') (the type comes from @molecule/api-resource-device) is the typed accessor: do NOT hand-type a get<…>('device') generic, because a wrong one still compiles and hands you undefined for whatever field you invented (device.id), which is how a run signed a token no request ever accepted. Read the value the call RETURNS, and check it is there before signing.

The token is bound to session.deviceId, and verifyMiddleware rejects any session whose device row does not exist (that is how signing out revokes a session). So this function REFUSES to mint a token without one: it throws when session.userId or session.deviceId is missing or is not a non-empty string, naming the field and the call that supplies it, instead of letting the client discover it as a 401 on every later request. Do the device step on every path that signs a user in (register, login, OAuth, password reset), as handlers/logIn.ts does: a placeholder

(description truncated — read the full text in node_modules/@molecule/api-resource-user/dist/authorization.d.ts.)

function set(
  _req: MoleculeRequest,
  res: MoleculeResponse,
  session: {
    userId: string
    deviceId: string
    id?: string | undefined
    oauthServer?: string | undefined
    oauthId?: string | undefined
  },
): string | null
  • _req — The request object (unused; reserved for handler signature parity).
  • res — The response object.
  • session — The session, carrying the userId AND the deviceId the token is bound to.

Returns: The signed JWT token string, or null if signing failed.

authorization.VerifyMiddlewareOptions

Options for verifyMiddleware.

interface VerifyMiddlewareOptions {
  /**
   * Decide whether an aging token may be transparently re-issued for this
   * session. The auto-refresh calls `set()`, which rewrites the session cookies
   * AND the non-httpOnly `mol_auth` presence hint — the signal the auth client
   * uses to hydrate a signed-in user on the next page load. Return `false` to
   * skip the refresh for sessions that must never mint that hint (e.g. an
   * anonymous/guest session whose httpOnly cookie authenticates API calls but
   * whose user must not appear signed-in client-side). A thrown error is
   * treated as `false` (refresh is best-effort and fails safe: no hint).
   * Defaults to refreshing every session.
   * @param session - The verified session about to be refreshed.
   * @param req - The current request.
   * @returns Whether the session may be re-issued.
   */
  shouldRefreshSession?: (session: Session, req: MoleculeRequest) => boolean | Promise<boolean>
  /**
   * Whether the `token` cookie is a session on this mount, in BOTH directions.
   * Defaults to `true` (browser clients sign in once and every page request carries
   * the cookie).
   *
   * Pass `false` for a token-only JSON API. Then the cookie is not a credential here:
   * the middleware does not resolve a session from it (and drops one an earlier, laxer
   * mount already resolved from it), and `set` does not write it either — so a
   * client that keeps a cookie jar (an HTTP test runner, `curl -b`) never picks up a
   * session it should not have, and stops answering 200 on the step its spec calls
   * "no auth".
   */
  allowCookieSession?: boolean
  /**
   * The `Authorization` scheme this mount accepts and mints: `'Bearer'` (default), or
   * `'Token'` for a Django REST Framework / RealWorld-style API. Defaults to the app's
   * `AUTH_SCHEME` environment value.
   *
   * This is a LABEL, not a second auth mechanism: every route the mount covers accepts the
   * same verifiable JWT, whether it arrives as `Bearer <jwt>` or `Token <jwt>` (both stay
   * accepted). The option decides what `set` writes into the sign-in response header
   * — and therefore what the client sends back and what its HTTP client must be configured
   * with (`createFetchClient({ baseURL, authScheme: 'Token' })`).
   */
  scheme?: string
}
authorization.verifyMiddleware(options)

Middleware that verifies the JWT token from the Authorization header and sets res.locals.session. Accepts Bearer and Token (Django REST / RealWorld) plus the configured scheme, on EVERY route the mount covers. Falls back to cookie-based session for browser clients unless allowCookieSession is false — which also stops set() writing the session cookie for this request. Records the scheme for set() and, when the token came from the header, records the raw token in res.locals[AUTH_TOKEN_LOCALS_KEY]. Auto-refreshes tokens older than JWT_REFRESH_TIME.

function verifyMiddleware(options?: VerifyMiddlewareOptions): MoleculeRequestHandler
  • options — VerifyMiddlewareOptions controlling the auto-refresh, cookie policy and scheme.

Returns: An Express-compatible middleware function.

authorizers

Re-exported as a namespace — reach every member below through it (e.g. authorizers.<name>).

authorizers.auth()

Middleware that checks if the request has an authenticated session (res.locals.session.userId). Calls next() on success or next('Unauthorized') on failure.

function auth(): MoleculeRequestHandler

Returns: An Express-compatible middleware function.

authorizers.authSelf()

Middleware that checks if the authenticated user's ID matches the :id route parameter. Ensures users can only access their own resource. Calls next() on match or next('Unauthorized') otherwise.

As a convenience, :id === 'me' is treated as the current authenticated user — req.params.id is rewritten to the session's userId so downstream handlers receive the real id. This lets pages call /api/users/me without needing the user's UUID in scope.

function authSelf(): MoleculeRequestHandler

Returns: An Express-compatible middleware function.

authorizers.RateLimitAuthOptions

Configuration for rateLimit.

interface RateLimitAuthOptions {
  /**
   * Key namespace so each protected surface gets an isolated bucket
   * (e.g. `'login'`, `'forgot-password'`, `'2fa'`). Buckets never collide
   * across scopes.
   */
  scope: string

  /** Sliding window in milliseconds (default: 15 minutes). */
  windowMs?: number

  /**
   * Maximum attempts per window for the per-account bucket before a 429
   * (default: 10). This is the tight brute-force gate; a legitimate user makes
   * only a handful of attempts, so it never trips for them.
   */
  max?: number

  /**
   * Maximum attempts per window for the per-IP bucket (default: 60). Kept
   * higher than `RateLimitAuthOptions.max` so shared public IPs aren't
   * locked out; primarily catches single-source flooding and password-spray
   * across many accounts, and is the only guard when no account is resolvable
   * (e.g. an OAuth login carrying no email).
   */
  ipMax?: number

  /**
   * Optional extractors for an account identifier (submitted email/username,
   * the target user id, etc.). When one resolves, the request is ALSO bucketed
   * per account — so a distributed attack against a single account is throttled
   * even when the source IP rotates, and the second factor is temp-locked after
   * `max` consecutive misses regardless of IP. Each extractor is tried; every
   * value that resolves contributes a bucket.
   */
  accountFrom?: ReadonlyArray<(req: MoleculeRequest) => string | undefined>
}
authorizers.rateLimit(options)

Creates an authorizer middleware that brute-force-protects an auth endpoint.

Consumes a rate-limit token per request, keyed by client IP and — where resolvable — by account identifier, returning HTTP 429 once the low ceiling is exceeded. This closes the unthrottled-auth default that left passwords and the 6-digit TOTP second factor brute-forceable on mlcl-generated apps.

Availability is preserved over absolute enforcement: if no rate-limit provider is bonded (an app deliberately removed it) or the limiter itself errors, the request is allowed through (with a logged warning/error) so a limiter outage never locks out every legitimate login. Generated apps wire the default in-memory provider via the registry's alwaysInclude/ defaultProvider, so the throttle is active out of the box.

function rateLimit(options: RateLimitAuthOptions): MoleculeRequestHandler
  • options — Scope, window, ceiling, and optional account-key extractors.

Returns: A MoleculeRequestHandler to place ahead of the protected handler.

authorizers.loginAccountKey(req)

Account-identifier extractor for the login endpoint: the submitted username or email. Bucketing on this temp-locks a single targeted account after max failed attempts even across rotating IPs (which also covers TOTP-via-login brute force, since the login handler verifies the second factor).

function loginAccountKey(req: MoleculeRequest): string | undefined
  • req — The incoming request.

Returns: The submitted username or email (if present).

authorizers.emailAccountKey(req)

Account-identifier extractor for the forgot-password endpoint: the submitted email.

function emailAccountKey(req: MoleculeRequest): string | undefined
  • req — The incoming request.

Returns: The submitted email (if present).

authorizers.paramIdAccountKey(req)

Account-identifier extractor for the verify-two-factor endpoint: the target user id in the route params. Temp-locks the second factor per account after max consecutive misses.

function paramIdAccountKey(req: MoleculeRequest): string | undefined
  • req — The incoming request.

Returns: The target user id (if present).

authorizers.requireWebhookAuthenticity()

Middleware guarding the public POST /users/payment-notification/:provider endpoint against forged server-to-server (Apple/Google IAP) notifications.

Self-verifying webhook providers (e.g. Stripe) authenticate the payload by cryptographic signature INSIDE the handler (constructEvent), so they pass through untouched.

Server-to-server providers (Apple/Google RTDN) carry no transport signature — their endpoint is otherwise open to anyone — so a shared secret is REQUIRED by default ("IAP apps are not open by default"). The secret is supplied via the x-payment-notification-secret header or ?secret= query param (configure the provider's notification URL accordingly) and is compared in constant time against PAYMENT_NOTIFICATION_SECRET. Deployments that rely solely on the provider's in-handler receipt re-verification can opt out with PAYMENT_NOTIFICATION_REQUIRE_SECRET=false — but the secure behavior ships ON by default and is never relaxed based on request presence.

function requireWebhookAuthenticity(): MoleculeRequestHandler

Returns: An Express-compatible middleware function.

handlers

Re-exported as a namespace — reach every member below through it (e.g. handlers.<name>).

handlers.CreateRequest

Request body for user creation, including password and optional device name.

interface CreateRequest extends MoleculeRequest {
  body: types.CreateProps & {
    password?: string
    deviceName?: string
  }
}
handlers.create(resource)

Creates a user with username and password. Validates username uniqueness and email format, hashes the password into a separate secrets table, creates a session device via the bonded DeviceService, and sets JWT authorization on the response.

schema is optional — omit it and this package's propsSchema validates the row. It must be a Zod object (anything exposing safeParse): pass createResource({ oauthServers, planKeys }) or your own extended schema only when the app constrains or extends user props. A stub such as schema: {} is not a schema — the FIRST request answers 400 resource.error.unknownError with TypeError: schema.safeParse is not a function, after no row has been written.

Statuses: 201 { props, accessToken, user }; 400 user.error.usernameInvalid / user.error.bodyNestedUnderUser / user.error.passwordRequired / user.error.passwordTooLong / user.error.passwordTooShort / user.error.emailInvalid / user.error.badRequest, or resource.error.unknownError when props fail the schema; 403 user.error.usernameUnavailable / user.error.emailAlreadyRegistered; 500 user.error.failedToHashPassword / user.error.failedToCreateSession.

function create({ name, tableName, schema = propsSchema }: types.HandlerResource): (
  req: CreateRequest,
  res: MoleculeResponse,
) => Promise<
  | { statusCode: number; body: { error: string; errorKey: string; props?: undefined } }
  | {
      statusCode: number
      body: { props: Props & Record<string, unknown>; error?: undefined; errorKey?: undefined }
    }
  | {
      statusCode: number
      body: {
        props: Record<string, unknown>
        accessToken: string | null
        user: Record<string, unknown>
      }
    }
>
  • resource — The user resource configuration: { name, tableName, schema? }.
  • resource.name — The resource name used in error messages.
  • resource.tableName — The database table name for users.
  • resource.schema — Optional Zod schema for the created user props. Defaults to propsSchema.

Returns: A request handler that responds with { statusCode: 201, body: { props, accessToken, user } } on success.

handlers.del(resource)

Deletes a user and their associated data. Deletes the user row FIRST, then the secrets, the devices (via the bonded DeviceService) and the payment records (via the bonded PaymentRecordService).

The row goes first so that a failed delete changes nothing: deleting the secrets first meant a row delete that then failed left an account with no password hash — no password sign-in, and a retried delete that no longer asks for one. When the row delete fails nothing else has been touched; when it succeeds, a failure in the clean-up after it leaves records that belong to no account (logged), never an account without its password.

function del({
  name: _name,
  tableName,
  schema: _schema,
}: types.HandlerResource): (
  req: MoleculeRequest,
) => Promise<
  | { statusCode: number; body: { error: string; errorKey: string; props?: undefined } }
  | { statusCode: number; body: { props: { id: string }; error?: undefined; errorKey?: undefined } }
>
  • resource — The user resource configuration (name, tableName, schema).
  • resource.name — The resource name.
  • resource.tableName — The database table name for users.
  • resource.schema — Optional. Ignored — deletion does not validate props.

Returns: A request handler that responds with { statusCode: 200, body: { props: { id } } } on success.

handlers.ForgotPasswordRequest

Request body for password reset initiation.

interface ForgotPasswordRequest extends MoleculeRequest {
  body: {
    email?: string
  }
}
handlers.forgotPassword(resource)

Generates a UUID password reset token, stores it in the secrets table, and sends a reset email via the bonded email provider (if available). Always returns 200 regardless of whether the email exists, to prevent user enumeration.

function forgotPassword({
  name: _name,
  tableName,
  schema: _schema,
}: types.HandlerResource): (
  req: ForgotPasswordRequest,
) => Promise<
  | { statusCode: number; body: { error: string; errorKey: string; success?: undefined } }
  | { statusCode: number; body: { success: boolean; error?: undefined; errorKey?: undefined } }
>
  • resource — The user resource configuration (name, tableName, schema).
  • resource.name — The resource name.
  • resource.tableName — The database table name for users.
  • resource.schema — Optional. Ignored — the reset flow reads and writes secrets directly.

Returns: A request handler that responds with { statusCode: 200, body: { success: true } }.

handlers.LogInRequest

Request body for user login, supporting password, reset token, and 2FA flows.

interface LogInRequest extends MoleculeRequest {
  body: {
    username?: string
    email?: string
    password?: string
    passwordResetToken?: string
    twoFactorToken?: string
    deviceName?: string
  }
}
handlers.logIn(resource)

Logs in a user by username or email. Supports password authentication, password reset token authentication (1-hour expiry), and optional two-factor verification via @molecule/api-two-factor. On success, creates/updates a device via the bonded DeviceService, sets JWT authorization, and notifies other devices about the new login.

schema is optional and NOT used here: login reads stored rows and compares credentials, so there are no props to validate. Statuses: 200 { props, accessToken, user }; 206 { twoFactorRequired: true } when the account has 2FA enabled and no twoFactorToken was sent; 403 user.error.invalidCredentials (wrong password / unknown account / spent reset token) or user.error.invalidTwoFactorToken; 500 user.error.failedToCreateSession / user.error.twoFactorVerificationUnavailable / user.error.loginFailed.

function logIn({ tableName }: types.HandlerResource): (
  req: LogInRequest,
  res: MoleculeResponse,
) => Promise<
  | {
      statusCode: number
      body: {
        error: string
        errorKey: string
        twoFactorRequired?: undefined
        props?: undefined
        accessToken?: undefined
        user?: undefined
      }
    }
  | {
      statusCode: number
      body: {
        twoFactorRequired: boolean
        error?: undefined
        errorKey?: undefined
        props?: undefined
        accessToken?: undefined
        user?: undefined
      }
    }
  | {
      statusCode: number
      body: {
        props: Record<string, unknown>
        accessToken: string | null
        user: Record<string, unknown>
        error?: undefined
        errorKey?: undefined
        twoFactorRequired?: undefined
      }
    }
>
  • resource — The user resource configuration: { name, tableName, schema? }.
  • resource.name — The resource name (unused by this handler).
  • resource.tableName — The database table name for users.
  • resource.schema — Optional. Ignored — login validates credentials, not props.

Returns: A request handler that responds with { statusCode: 200, body: { props, accessToken, user } } on success, or { statusCode: 206, body: { twoFactorRequired: true } } when 2FA is needed.

handlers.LogInOAuthRequest

Request body for OAuth login, including the OAuth server name, authorization code, and PKCE verifier.

interface LogInOAuthRequest extends MoleculeRequest {
  body: {
    server?: string
    code?: string
    state?: string
    codeVerifier?: string
    redirect_uri?: string
    deviceName?: string
  }
}
handlers.logInOAuth(resource)

Logs in or creates a user via OAuth. Verifies the authorization code with the bonded OAuth provider (get('oauth', serverName)), then either finds an existing user by oauthServer+oauthId or creates a new one with a unique username. Sets JWT authorization and creates a session device.

schema is optional and defaults to this package's propsSchema; supply one only when the app constrains or extends user props. It must expose safeParse — a stub fails the OAuth account-creation request with 400 resource.error.unknownError.

function logInOAuth({ name, tableName, schema = propsSchema }: types.HandlerResource): (
  req: LogInOAuthRequest,
  res: MoleculeResponse,
) => Promise<
  | {
      statusCode: number
      body: { error: string; errorKey: string; twoFactorRequired?: undefined; props?: undefined }
    }
  | {
      statusCode: number
      body: { error: string; errorKey: string; twoFactorRequired: boolean; props?: undefined }
    }
  | {
      statusCode: number
      body: {
        props: Record<string, unknown>
        error?: undefined
        errorKey?: undefined
        twoFactorRequired?: undefined
      }
    }
>
  • resource — The user resource configuration: { name, tableName, schema? }.
  • resource.name — The resource name used in error messages.
  • resource.tableName — The database table name for users.
  • resource.schema — Optional Zod schema validated when a new OAuth account is created. Defaults to propsSchema.

Returns: A request handler that responds with { statusCode: 200, body: { props } } on success.

handlers.logout()

Logs the current user out. Revokes the session device server-side (so the JWT is rejected on its next use — covering EVERY copy of the token, not just this browser) and clears the credential cookies.

[M1-1] Under the in-memory-token model the httpOnly token cookie is the persistent credential, so logout MUST clear it (and the mol_auth presence hint) — otherwise the app's cookie-based session restore (GET /users/me on init) would silently log the user back in on the next page load, and a client-side-only logout would be cosmetic (the cookie would still authenticate direct API calls). Cookie-authed via auth (no :id; the session identifies the user).

function logout(): (
  _req: MoleculeRequest,
  res: MoleculeResponse,
) => Promise<{ statusCode: number; body: { success: boolean } }>

Returns: A request handler responding { statusCode: 200, body: { success: true } }.

handlers.oauthAuthorize()

OAuth initiation — GET /users/oauth/:provider.

The missing first half of the OAuth flow whose second half is logInOAuth (POST /users/log-in/oauth): generates the CSRF state and a per-session PKCE S256 verifier, stores BOTH in httpOnly cookies (the exact names + attributes logInOAuth reads and clears — getAuthCookieName('oauth_state') / ('oauth_verifier') with getAuthCookieOptions()), then 302-redirects the browser to the provider's authorization URL.

Provider knowledge stays in the bond: the handler asks the bonded OAuth provider (get('oauth', :provider)) for its authorize URL via its optional getAuthorizeUrl builder (see @molecule/api-oauth). A provider that is not bonded, lacks the builder, or is unconfigured (builder returns null, e.g. missing client id) yields a clean 404/503 — never a crash.

The redirect_uri sent to the provider is APP_ORIGIN plus an optional validated same-origin redirect_to path from the query string (the page that initiated the flow, e.g. /login, where the app's OAuth callback handling is mounted). The path is sanitized so this can never become an open redirect; the state cookie — not redirect_uri — is the CSRF control.

function oauthAuthorize(): (
  req: MoleculeRequest,
  res: MoleculeResponse,
) => Promise<
  | { statusCode: number; body: { error: string; errorKey: string }; headers?: undefined }
  | { statusCode: number; headers: { Location: string }; body: undefined }
>

Returns: A request handler that responds 302 with a Location header pointing at the provider's authorization URL.

handlers.billingPortal()

Opens the bonded provider's hosted billing portal for the user, so they can update their payment method, see invoices, and cancel — the self-service surface an app otherwise has to build (and usually doesn't, leaving a subscriber with no way out but support).

Reads the provider name from the :provider route parameter and returns the portal URL for the client to open. Providers whose purchases are managed by the platform store (Apple/Google) implement no portal and answer 400 — their subscribers manage the subscription in the store app.

The user is returned to the APP origin on exit (never the API origin, whose host holds no session cookie); an app-relative returnPath in the request body sends them back to the exact page they opened the portal from.

function billingPortal(): (req: MoleculeRequest) => Promise<
  | {
      statusCode: number
      body: { error: string; errorKey: string; id?: undefined; url?: undefined }
    }
  | {
      statusCode: number
      body: { id: string; url: string; error?: undefined; errorKey?: undefined }
    }
>

Returns: A request handler that responds { url, id } with the portal session.

handlers.handlePaymentNotification(resource)

Generic payment notification handler that works with any bonded PaymentProvider. Reads the provider name from the :provider route parameter and dispatches based on notificationFlow:

  • 'webhook' (Stripe-style): Calls handleWebhookEvent() to parse signed webhook payloads, then looks up the user via PaymentRecordService by customer ID.
  • Default (Apple/Google-style): Calls parseNotification() for server-to-server notifications, then looks up the user via PaymentRecordService by transaction ID.

Updates the user's plan on renewal/change or clears it on cancellation/expiry/refund/revocation. Always returns 200 to acknowledge receipt.

function handlePaymentNotification({
  name,
  tableName,
  schema: _schema,
}: types.HandlerResource): (
  req: MoleculeRequest,
) => Promise<{ statusCode: number; body: string } | { statusCode: number; body: { error: string } }>
  • resource — The user resource configuration (name, tableName, schema).
  • resource.name — The resource name.
  • resource.tableName — The database table name for users.
  • resource.schema — Optional. Ignored — notifications validate with updatePlanPropsSchema.

Returns: A request handler for payment provider notifications.

handlers.verifyPayment(resource)

Generic payment verification handler that works with any bonded PaymentProvider. Reads the provider name from the :provider route parameter and dispatches based on verifyFlow:

  • 'subscription' (Stripe-style): Calls verifySubscription(subscriptionId).
  • Default (Apple/Google-style): Calls verifyReceipt() or verifyPurchase() with a receipt string and the plan's platform product ID.

On successful verification, updates the user's plan (planKey, planExpiresAt, planAutoRenews) and stores the payment record via the bonded PaymentRecordService.

function verifyPayment({ name, tableName, schema: _schema }: types.HandlerResource): (
  req: MoleculeRequest,
) => Promise<
  | { statusCode: number; body: { error: string; errorKey: string; props?: undefined } }
  | {
      statusCode: number
      body: {
        props: Pick<Props, 'updatedAt'> & {
          planKey?: string | undefined
          planExpiresAt?: string | undefined
          planAutoRenews?: boolean | undefined
        }
        error?: undefined
        errorKey?: undefined
      }
    }
>
  • resource — The user resource configuration (name, tableName, schema).
  • resource.name — The resource name.
  • resource.tableName — The database table name for users.
  • resource.schema — Optional. Ignored — plan fields are written directly after provider verification.

Returns: A request handler for payment verification.

handlers.read(resource)

Reads a user by ID from the database. Attaches plan info (with expiration/renewal status) via the bonded PlanService if available. When reading the authenticated user's own record, also includes two-factor authentication status from the secrets table.

function read({
  name: _name,
  tableName,
  schema: _schema,
}: types.HandlerResource): (
  req: MoleculeRequest,
  res: MoleculeResponse,
) => Promise<{ statusCode: number; body: Record<string, unknown> }>
  • resource — The user resource configuration (name, tableName, schema).
  • resource.name — The resource name.
  • resource.tableName — The database table name for users.
  • resource.schema — Optional. Ignored — reads do not validate props.

Returns: A request handler that responds with { statusCode: 200, body: { props, plan?, twoFactorEnabled? } }.

handlers.readSelf(resource)

Reads the AUTHENTICATED user from the session — no :id param. Backs GET /users/me, which the app's auth client calls on init to RESTORE the session after a full page load.

[M1-1] The bearer token is held in memory (a localStorage copy is XSS-exfiltratable and is forbidden), so it is lost on reload; the httpOnly session cookie is the persistent credential. The auth middleware reads that cookie into res.locals.session, and this handler returns the current user so the client can re-establish authenticated state WITHOUT the token ever being exposed to JavaScript. Always self, so it mirrors read's self branch (plan + two-factor status).

Token-only clients get the token back as accessToken. When the request authenticated with an Authorization header (any accepted scheme — Bearer or Token), the middleware records that raw JWT and this response echoes it, so a RealWorld-style client can restore { user, token } from GET /user the same way login and registration return it. A session resolved from the httpOnly cookie NEVER gets this field: exposing that token to JavaScript would defeat the cookie.

function readSelf({
  tableName,
}: types.HandlerResource): (
  _req: MoleculeRequest,
  res: MoleculeResponse,
) => Promise<{ statusCode: number; body: Record<string, unknown> }>
  • resource — The user resource configuration: { name, tableName, schema? } (name and schema are optional here).
  • resource.tableName — The database table name for users.

Returns: A request handler responding { statusCode: 200, body: { props, plan?, twoFactorEnabled?, accessToken? } }, or 401 when unauthenticated.

handlers.RESET_TOKEN_TTL_MS

How long a password-reset token stays valid, in milliseconds. Shared with logIn, which accepts the same token as a login credential and must apply the SAME gate — the same constant AND the same boundary (tokenAge equal to the TTL is still valid in both), or one path becomes the looser way in for the other's one-hour credential.

const RESET_TOKEN_TTL_MS: number
handlers.ResetPasswordRequest

Request body for confirming a password reset using a token.

interface ResetPasswordRequest extends MoleculeRequest {
  body: {
    token?: string
    password?: string
  }
}
handlers.resetPassword(resource)

Confirms a password reset by validating the one-time token previously generated by forgotPassword, then hashing and storing the new password and clearing the token.

Token TTL is one hour. The handler responds with 400 for missing fields, 400 for passwords that fail length validation (matching updatePassword), and 401 for tokens that are unknown, expired, or already consumed.

On success, all existing device sessions are invalidated to force a fresh login, mirroring the behavior of updatePassword.

The new password and the token clear are written as ONE WHERE-guarded update matching the verified token hash, so the link is consumed atomically: of two concurrent requests holding the same link, only the one that flips the token to NULL matches a row — the other is refused with 401 and cannot overwrite the winner's password.

function resetPassword({
  name: _name,
  tableName,
  schema: _schema,
}: types.HandlerResource): (
  req: ResetPasswordRequest,
) => Promise<
  | { statusCode: number; body: { error: string; errorKey: string; success?: undefined } }
  | { statusCode: number; body: { success: boolean; error?: undefined; errorKey?: undefined } }
>
  • resource — The user resource configuration (name, tableName, schema).
  • resource.tableName — The database table name for users.
  • resource.schema — Optional. Ignored — the reset flow writes the password hash directly.

Returns: A request handler that responds with { statusCode: 200, body: { success: true } } on success.

handlers.update(resource)

Updates a user's profile fields (username, name, email). Validates username format (alphanumeric, max 255 chars), email format, and uniqueness of both fields against other users before delegating to the resource update utility. schema is optional and NOT used here: this handler validates the partial update with the package's own extendedUpdatePropsSchema (username, name, email, avatar, bio, plus oauthData), so callers need not invent one. Statuses: 200 updated props; 400 for an invalid username/email, an oversized avatar/bio, a malformed oauthData, a missing currentPassword, or no updatable fields; 403 user.error.usernameUnavailable / user.error.emailAlreadyRegistered / user.error.currentPasswordIncorrect; 500 user.error.failedToUpdateUser.

function update({ name, tableName }: types.HandlerResource): (req: MoleculeRequest) => Promise<
  | { statusCode: number; body: { error: string; errorKey: string; props?: undefined } }
  | {
      statusCode: number
      body: {
        props: Pick<Props, 'updatedAt'> & Record<string, unknown>
        error?: undefined
        errorKey?: undefined
      }
    }
>
  • resource — The user resource configuration: { name, tableName, schema? }.
  • resource.name — The resource name used in error messages.
  • resource.tableName — The database table name for users.
  • resource.schema — Optional. Ignored — updates validate with this package's update schema.

Returns: A request handler that responds with the updated user props on success.

handlers.UpdatePasswordRequest

Request body for password update, with current password verification and new password.

interface UpdatePasswordRequest extends MoleculeRequest {
  body: {
    currentPassword?: string
    newPassword?: string
  }
}
handlers.updatePassword(resource)

Updates a user's password. If the user already has a password hash, the current password must be provided and verified first. The new password is hashed via @molecule/api-password and stored in the secrets table.

Every OTHER session is revoked; the caller's own is kept. The device rows of all other devices are deleted, so a stolen token on another device dies — but the device this request arrived on stays, and the token that made the request (the Authorization header the client is holding) keeps working. A RealWorld-style suite changes the password and then keeps using the same token for the next request; revoking the caller too is what answered its second PUT /user with 401 (xr-flash-m167, ~2 min lost). Do not fold a per-user secret into the JWT: the device row is the revocation lever. A caller with no session (a handler invoked outside a request) falls back to revoking every device.

function updatePassword({
  name: _name,
  tableName,
  schema: _schema,
}: types.HandlerResource): (
  req: UpdatePasswordRequest,
  res?: MoleculeResponse,
) => Promise<
  | { statusCode: number; body: { error: string; errorKey: string; success?: undefined } }
  | { statusCode: number; body: { success: boolean; error?: undefined; errorKey?: undefined } }
>
  • resource — The user resource configuration (name, tableName, schema).
  • resource.name — The resource name.
  • resource.tableName — The database table name for users.
  • resource.schema — Optional. Ignored — this handler writes the password hash directly.

Returns: A request handler that responds with { statusCode: 200, body: { success: true } } on success.

handlers.UpdatePlanRequest

Request body for plan update, containing the target plan key.

interface UpdatePlanRequest extends MoleculeRequest {
  body: {
    planKey?: string
    /**
     * Units of the plan to buy — seats, on a per-seat plan. Omitted means one.
     *
     * Read from the request because only the app knows what a unit means: the
     * plan catalogue describes a price, not how many of it this buyer wants.
     * Ignored by flat-priced plans, whose providers receive no quantity at all.
     */
    quantity?: number
  }
}
handlers.updatePlan(resource)

Updates a user's subscription plan. Uses the bonded PlanService to look up plan metadata and delegates to the appropriate bonded PaymentProvider based on the plan's platformKey. Handles cross-platform subscription cancellation when switching providers. Returns a checkout URL (status 201) for new subscriptions, or directly updates the user record for in-place changes and free plan downgrades.

function updatePlan({ name, tableName, schema: _schema }: types.HandlerResource): (
  req: UpdatePlanRequest,
) => Promise<
  | { statusCode: number; body: { error: string; errorKey: string; props?: undefined } }
  | {
      statusCode: number
      body: {
        props: Pick<Props, 'updatedAt'> & Record<string, unknown>
        error?: undefined
        errorKey?: undefined
      }
    }
  | { statusCode: number; body: { checkoutUrl: string } }
>
  • resource — The user resource configuration (name, tableName, schema).
  • resource.name — The resource name.
  • resource.tableName — The database table name for users.
  • resource.schema — Optional. Ignored — plan updates validate with updatePlanPropsSchema.

Returns: A request handler for plan updates.

handlers.VerifyTwoFactorRequest

Request body for two-factor authentication operations (setup, enable, or disable).

interface VerifyTwoFactorRequest extends MoleculeRequest {
  body: {
    token?: string
    action?: 'setup' | 'enable' | 'disable'
  }
}
handlers.verifyTwoFactor(resource)

Handles two-factor authentication lifecycle via @molecule/api-two-factor:

  • setup: Generates a TOTP secret and returns QR code URLs for authenticator apps.
  • enable: Verifies a TOTP token against the pending secret, then activates 2FA.
  • disable: Verifies a TOTP token against the active secret, then deactivates 2FA.
function verifyTwoFactor({ name: _name, tableName, schema: _schema }: types.HandlerResource): (
  req: VerifyTwoFactorRequest,
) => Promise<
  | { statusCode: number; body: { error: string; errorKey: string; twoFactorEnabled?: undefined } }
  | {
      statusCode: number
      body: {
        secret: string
        keyUrl: string
        QRImageUrl: string
        error?: undefined
        errorKey?: undefined
        twoFactorEnabled?: undefined
      }
    }
  | {
      statusCode: number
      body: { twoFactorEnabled: boolean; error?: undefined; errorKey?: undefined }
    }
>
  • resource — The user resource configuration (name, tableName, schema).
  • resource.name — The resource name.
  • resource.tableName — The database table name for users.
  • resource.schema — Optional. Ignored — 2FA state is written to the secrets table directly.

Returns: A request handler for two-factor authentication operations.

types

Re-exported as a namespace — reach every member below through it (e.g. types.<name>).

types.Props

User props type inferred from schema — the row shape of the users table, which holds no password.

type Props = z.infer<typeof propsSchema>
types.SecretProps

Secret props type — the row shape of the usersSecrets table, whose id is the user's id.

type SecretProps = z.infer<typeof secretPropsSchema>
types.CreateProps

Create Props type.

type CreateProps = z.infer<typeof createPropsSchema>
types.CreateSecretProps

Create Secret Props type.

type CreateSecretProps = z.infer<typeof createSecretPropsSchema>
types.CreateOAuthProps

Create O Auth Props type.

type CreateOAuthProps = z.infer<typeof createOAuthPropsSchema>
types.UpdateProps

Update Props type.

type UpdateProps = z.infer<typeof updatePropsSchema>
types.UpdatePasswordSecretProps

Update Password Secret Props type.

type UpdatePasswordSecretProps = z.infer<typeof updatePasswordSecretPropsSchema>
types.UpdatePlanProps

Update Plan Props type.

type UpdatePlanProps = z.infer<typeof updatePlanPropsSchema>
types.VerifyTwoFactorProps

Verify Two Factor Props type.

type VerifyTwoFactorProps = z.infer<typeof verifyTwoFactorPropsSchema>
types.VerifyTwoFactorSecretProps

Verify Two Factor Secret Props type.

type VerifyTwoFactorSecretProps = z.infer<typeof verifyTwoFactorSecretPropsSchema>
types.Session

User session data (userId, email, role, permissions, metadata) inferred from sessionSchema.

type Session = z.infer<typeof sessionSchema>
types.Resource

An object describing the user resource.

type Resource<T = unknown> = resourceTypes.Resource<T>
types.HandlerResource

Configuration accepted by every user handler factory (handlers.create, handlers.logIn, handlers.update, …).

name and tableName are the caller's to supply, because an app may mount the user resource on its own table. schema is OPTIONAL — no factory requires you to invent one:

  • handlers.create and handlers.logInOAuth validate the props they write and default to this package's propsSchema when schema is omitted.
  • handlers.update validates with the package's updatePropsSchema and ignores a supplied schema.
  • The remaining handlers read/write rows directly and never look at schema.

Omission is NOT "no validation": the handlers that validate use propsSchema. Pass createResource({ oauthServers, planKeys }) (or the resource export) when the app constrains oauthServer / planKey, and pass your own extended Zod object only when you add fields.

interface HandlerResource {
  /** The resource name used in error messages (e.g. `'User'`). */
  name: string
  /** The database table name for users. */
  tableName: string
  /**
   * Optional Zod schema validated by `handlers.create` / `handlers.logInOAuth`.
   * Defaults to `propsSchema`. Must expose `safeParse`; a stub object is not
   * a schema and the first request answers 400 `resource.error.unknownError` with
   * `TypeError: schema.safeParse is not a function`.
   */
  schema?: unknown
}

utilities

Re-exported as a namespace — reach every member below through it (e.g. utilities.<name>).

utilities.fetchAvatarDataUri(url)

Downloads an OAuth provider's profile image and re-hosts it as an inline data: URI so the stored avatar never references a third-party domain (provider URLs expire, and hot-linking leaks every viewer's request to the provider's CDN).

Best-effort by design: any refusal or failure — non-https URL, private or unresolvable host, timeout, non-image content type, oversized body — returns undefined (logged at debug) so OAuth login/creation never fails over an avatar.

function fetchAvatarDataUri(url: string): Promise<string | undefined>
  • url — The provider profile-image URL (OAuthUserProps.avatar).

Returns: The image as a data: URI within MAX_AVATAR_LENGTH, or undefined.

utilities.getPlan(user)

Get a user's current plan info. Uses the bonded PlanService if available.

function getPlan(user: {
  id: string
  createdAt: string
  updatedAt: string
  username?: string | undefined
  name?: string | undefined
  email?: string | null | undefined
  emailVerified?: boolean | undefined
  avatar?: string | null | undefined
  bio?: string | null | undefined
  twoFactorEnabled?: boolean | undefined
  oauthServer?: string | undefined
  oauthId?: string | undefined
  oauthData?: Record<string, unknown> | undefined
  planKey?: string | undefined
  planExpiresAt?: string | undefined
  planAutoRenews?: boolean | undefined
}): Promise<Plan | PlanInfo | null>
  • user — The user props (needs planKey, planExpiresAt, planAutoRenews).

Returns: The user's plan info with expiration status, or null if no PlanService is bonded.

utilities.invalidateEntitlementsCache(userId)

Invalidates the entitlements plan-key cache for a user after their plan changed (paid upgrade via verifyPayment, webhook grant/cancellation via handlePaymentNotification, admin updatePlan).

@molecule/api-entitlements caches each user's effective plan key for its TTL (default 5 minutes) on the getEffectiveTier hot path. Without this call, a user who just PAID keeps seeing their old (free) tier — and a canceled user keeps premium — until the TTL lapses. The dependency is an OPTIONAL peer: apps that don't use entitlements simply have nothing to invalidate, so the import failure is a documented no-op.

function invalidateEntitlementsCache(userId: string): Promise<void>
  • userId — The user whose cached plan key must be dropped.
utilities.invalidateEntitlementsCacheSafe(userId)

Fire-and-forget variant of invalidateEntitlementsCache for call sites that must not await (or fail) the surrounding response on cache maintenance.

function invalidateEntitlementsCacheSafe(userId: string): void
  • userId — The user whose cached plan key must be dropped.
utilities.normalizeEmail(email)

Normalizes an email address for storage and lookup so case/whitespace variants of the same mailbox cannot bypass the email UNIQUE constraint (e.g. Foo@Example.com vs foo@example.com).

Trims surrounding whitespace and lowercases the whole address. While the local-part is technically case-sensitive per RFC 5321, every mainstream provider treats it case-insensitively, and the account-takeover risk of allowing variants far outweighs that pedantry. Both the local signup (create) and OAuth (logInOAuth) write paths MUST use this so the stored column and every collision lookup compare apples to apples.

function normalizeEmail(email: string | null | undefined): string | undefined
  • email — The raw email address (or null/undefined).

Returns: The normalized email, or undefined when the input is empty/blank.

utilities.notify(options)

Sends push notifications to all of a user's devices except the current one. Retrieves devices with active push subscriptions via the bonded DeviceService, then sends each notification via @molecule/api-push-notifications. Silently no-ops if either dependency is unavailable.

function notify({
  userId,
  deviceId,
  title,
  titleKey,
  body,
  bodyKey,
}: {
  userId: string
  deviceId?: string
  title: string
  titleKey?: string
  body: string
  bodyKey?: string
}): Promise<void>
  • options — Notification options.
  • options.userId — The user whose devices should receive the notification.
  • options.deviceId — The current device ID to exclude from notifications.
  • options.title — The notification title text.
  • options.titleKey — The i18n key for the title (sent as notification data for client-side localization).
  • options.body — The notification body text.
  • options.bodyKey — The i18n key for the body (sent as notification data for client-side localization).
utilities.SENSITIVE_USER_COLUMNS

User-row columns that must never appear in a self-read response.

const SENSITIVE_USER_COLUMNS: readonly [
  'oauthData',
  'oauthId',
  'emailConfirmationToken',
  'emailConfirmationTokenAt',
  'passwordResetToken',
  'passwordResetTokenAt',
  'passwordHash',
  'twoFactorSecret',
  'pendingTwoFactorSecret',
  'anonymousSecretHash',
  'anonymousSecret',
  'stripeCustomerId',
]
utilities.stripSensitiveUserColumns(user)

Returns a shallow copy of a user row with SENSITIVE_USER_COLUMNS removed. The input row is not mutated.

function stripSensitiveUserColumns(user: T): T
  • user — The full user row (as loaded by findById).

Returns: A copy of the row without secret columns.

utilities.USERNAME_RE

What a username may hold: letters, digits, dots, hyphens and underscores, starting with a letter or digit. A signup or profile update with anything else is refused rather than rewritten, so the stored username is always the one the user typed.

const USERNAME_RE: RegExp
utilities.USERNAME_STRIP_RE

The characters a username may not hold, for deriving one from a provider handle.

const USERNAME_STRIP_RE: RegExp
utilities.isNestedBody(body)

Whether a request body carries its signup fields under a user key ({ user: { username, password } }) instead of at the top level.

function isNestedBody(body: unknown): boolean
  • body — The request body.

Returns: True when the fields are nested under user.

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-bond ^1.0.1
  • @molecule/api-config ^1.0.1
  • @molecule/api-database ^1.0.1
  • @molecule/api-entitlements ^1.0.1
  • @molecule/api-i18n ^1.0.1
  • @molecule/api-jwt ^1.0.1
  • @molecule/api-locales-user ^1.0.1
  • @molecule/api-locales-user-payments ^1.0.1
  • @molecule/api-password ^1.0.1
  • @molecule/api-payments ^1.1.0
  • @molecule/api-push-notifications ^1.0.1
  • @molecule/api-rate-limit ^1.0.1
  • @molecule/api-resource ^1.0.1
  • @molecule/api-resource-device ^1.0.1
  • @molecule/api-secrets ^1.0.1
  • @molecule/api-two-factor ^1.0.1

Environment Variables

  • JWT_PRIVATE_KEY (required) — JWT signing key (RSA private)
    • Auto-generated at scaffold — no manual setup.
  • JWT_PUBLIC_KEY (required) — JWT verification key (RSA public)
    • Auto-generated at scaffold — no manual setup.

Runtime Dependencies

  • @molecule/api-bond
  • @molecule/api-config
  • @molecule/api-database
  • @molecule/api-entitlements
  • @molecule/api-i18n
  • @molecule/api-jwt
  • @molecule/api-locales-user
  • @molecule/api-locales-user-payments
  • @molecule/api-password
  • @molecule/api-payments
  • @molecule/api-push-notifications
  • @molecule/api-rate-limit
  • @molecule/api-resource
  • @molecule/api-resource-device
  • @molecule/api-secrets
  • @molecule/api-two-factor
  • uuid
  • zod

The user record is split across TWO schemas — pick the right one or you leak credentials:

  • Props (propsSchema) — SAFE, client-facing fields (username, name, email, emailVerified, twoFactorEnabled, plan). This is what handlers return and what lives in the users table.
  • SecretProps (secretPropsSchema) — SERVER-ONLY secrets: passwordHash, the TOTP twoFactorSecret (and its pending-setup value). Stored in the SEPARATE usersSecrets table (keyed by id = the user's id) and NEVER serialized to the client. Note the pair twoFactorEnabled (safe boolean, in Props) vs twoFactorSecret (secret, in SecretProps).

When you extend the user, put a secret (token, hash, key, provider refresh token) in SecretProps; put a display field in Props. Never add a secret to Props, never return a secrets-table value in a response or log, and never res.json(userRow) a raw DB row — return Props.

Auth is ALREADY wired globally (the router's verifyMiddleware → res.locals.session), so a handler reads the current user with getUserId(res) and does NOT add per-route auth middleware (see the auth skill). Scope every custom user query by the authenticated id.

A different wire format (RealWorld's { user: { … } }): the route handlers read a FLAT body (req.body.username, req.body.password) and are not a wire-format adapter — a nested body is answered 400 user.error.bodyNestedUnderUser. Unwrap the envelope before calling a handler, or write the route yourself. A username keeps exactly what was sent (letters, digits, ., -, _); anything else is answered 400 user.error.usernameInvalid, never rewritten.

A token-only JSON API: by default the token cookie that login sets IS a session, and authorization.set() writes it on every sign-in — so a client that keeps a cookie jar (an HTTP test runner, curl -b) stays signed in on a request that sends no Authorization header, and gets 200 on the step the suite calls "no auth". Mount verifyMiddleware({ allowCookieSession: false }) for an API whose clients send the header: that ONE option turns the cookie off in BOTH directions — the middleware no longer resolves a session from it (nor keeps one an earlier, laxer mount resolved), and set() stops writing token / sessionId / mol_auth, so even the sign-in response carries the Authorization header alone and the jar never fills. Set it on the mount in front of the routes that sign users in — the scaffold's api/src/App/router.ts — and check for a SECOND one: a request can pass several mounts (api/src/server.ts's preApiRouter runs before App/router.ts and covers all of /api), the last one it passes sets the policy both halves read, and a laxer mount later in the chain cannot re-admit the cookie. If the app mounts two, mount it once instead.

The RealWorld token recipe (detail). The scheme is configuration, not a second auth layer: AUTH_SCHEME=Token in the API's env, or verifyMiddleware({ scheme: 'Token', allowCookieSession: false }) at the mount, makes every covered route accept Authorization: Token <jwt> (Bearer stays accepted too) and labels the sign-in response header with that scheme. The token is already in the body — handlers.logIn and handlers.create return { props, accessToken, user }, and GET /users/me adds accessToken when the request authenticated with the header (never for a cookie-resolved session: that token must stay unreadable to JavaScript).

// Token-only API that speaks RealWorld's scheme.
router.use(verifyMiddleware({ scheme: 'Token', allowCookieSession: false }))
// POST /users and POST /users/log-in answer { props, accessToken, user };
// clients send `Authorization: Token <accessToken>` from then on.

The CLIENT labels its header the same way: authScheme: 'Token' is an option of the HTTP client's CONSTRUCTOR (createFetchClient({ baseURL, authScheme: 'Token' }), or the axios provider's createAxiosClient), never a property of getClient()'s result.

Revoking a session yourself: the middleware caches "this device exists" for 10 s per process, so a hand-written logout that deletes the devices row keeps authorizing that token until the cache expires — a check right after logout still answers 200. Call invalidateDeviceExistsCache(deviceId) (or invalidateAllDeviceExistsCache()) after the delete; the built-in log-out route already does.

On the CLIENT, the bearer token is held IN MEMORY only — a localStorage copy is XSS-exfiltratable and is forbidden. The session is restored after a reload via the httpOnly cookie + GET /users/me; don't persist the token yourself.

Client-facing endpoints (mounted under the app's /api prefix → /api/users/...). The auth CLIENT (useAuth() → login / register / logout / refresh) already wraps login / signup / logout — do NOT hand-roll those against the raw routes. The rest have NO client method; call them with raw http.*. Use these EXACT paths — a weak model guesses /api/auth/* or /api/user (singular), and neither exists:

  • POST /api/users/forgot-password — request a reset email (body { email })
  • POST /api/users/reset-password — confirm with the emailed token (body { token, password })
  • PATCH /api/users/:id — update profile fields (name, username, email, bio); NOT PUT /api/user
  • PATCH /api/users/:id/password — change password · DELETE /api/users/:id — delete account
  • PATCH /api/users/:id/plan — update the subscription plan (a paid plan with no existing subscription answers 201 { checkoutUrl }; send the browser there)
  • POST /api/users/:id/verify-payment/:provider — confirm a purchase server-side (body/query subscriptionId); the provider returns the buyer to the APP's /plan-updated?provider=…&sessionId=…, and THAT page calls this from the app origin so the session cookie applies. A hosted checkout must never redirect straight to this route on a separate API host: the top-level navigation carries no credentials, so it answers 401 and the paid plan is never granted.
  • POST /api/users/:id/billing-portal/:provider — open the provider's hosted billing portal (update card, invoices, cancel); responds { url }, optional body { returnPath } to come back to a specific app page. 404 user.payment.noBillingAccount when the user has no customer record yet
  • GET /api/users/me — the current user (session restore) · GET /api/users/:id — read one The full, authoritative route list is the routes export (see routes.ts).

E2E Tests

Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:

  • A new user can sign up with email + password and lands authenticated (the UI reflects the signed-in user, e.g. their name/menu appears).
  • Any flow that emails a link/code (signup verification, password reset) round-trips: the sandbox CAPTURES the message instead of sending — read it with the read_activity tool (filter type 'email') and follow the link/code in its payload; never mock the flow or modify production code to expose it.
  • Logging out and logging back in with the same credentials reaches the same account and its data.
  • The session survives a full page reload (restored via the httpOnly cookie + GET /users/me — never from a token persisted in localStorage).
  • A wrong password shows a visible error and does NOT authenticate.
  • Authenticated-only screens are unreachable when logged out (redirect to login or an explicit denial — never a blank page).
  • A profile/account edit (e.g. display name) persists across a reload.