@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-userHow 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, theoauth*andplan*columns. There is no password column here.usersSecrets—SecretProps:passwordHash,passwordResetToken(andpasswordResetTokenAt),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 }(userequalsprops, secret columns stripped); 400user.error.usernameInvalid/user.error.bodyNestedUnderUser/user.error.passwordRequired/user.error.passwordTooLong/user.error.passwordTooShort/user.error.emailInvalid/user.error.badRequest, orresource.error.unknownErrorwhen props fail the schema; 403user.error.usernameUnavailable/user.error.emailAlreadyRegistered; 500user.error.failedToHashPassword/user.error.failedToCreateSession.logIn(POST /api/users/log-in): 200{ props, accessToken, user }; 206{ twoFactorRequired: true }; 403user.error.invalidCredentials/user.error.invalidTwoFactorToken; 500user.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.tsJSDoc, 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-resourcethat 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 theoauthServerschema field.options.planKeys— Tuple of allowed plan key strings (e.g.['free', 'pro']). Constrains theplanKeyschema 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. ConstrainsoauthServerto a Zod enum.options.planKeys— Tuple of allowed plan key strings. ConstrainsplanKeyto 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 aLax/Strictcookie 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=Nonemakes it cross-site-sendable (which REQUIRESSecure— permitted over http on the potentially-trustworthylocalhost/127.0.0.1origins), andPartitioned(CHIPS) keeps it working when third-party cookies are blocked (Brave/Chrome), whichNonealone 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 (seegetAuthCookieName). NEVER weaken it. - Development, top-level →
{ secure: false, sameSite: 'lax', path: '/' }. The original dev posture (plain http, notSecure) — 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
authorization.ALLOW_COOKIE_SESSION_LOCALS_KEY
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 theuserIdAND thedeviceIdthe 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—VerifyMiddlewareOptionscontrolling 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 topropsSchema.
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 topropsSchema.
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): CallshandleWebhookEvent()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 withupdatePlanPropsSchema.
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): CallsverifySubscription(subscriptionId).- Default (Apple/Google-style): Calls
verifyReceipt()orverifyPurchase()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? }(nameandschemaare 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 withupdatePlanPropsSchema.
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.createandhandlers.logInOAuthvalidate the props they write and default to this package'spropsSchemawhenschemais omitted.handlers.updatevalidates with the package'supdatePropsSchemaand ignores a suppliedschema.- 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 (needsplanKey,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 byfindById).
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-factoruuidzod
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 theuserstable.SecretProps(secretPropsSchema) — SERVER-ONLY secrets:passwordHash, the TOTPtwoFactorSecret(and its pending-setup value). Stored in the SEPARATEusersSecretstable (keyed byid= the user's id) and NEVER serialized to the client. Note the pairtwoFactorEnabled(safe boolean, inProps) vstwoFactorSecret(secret, inSecretProps).
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); NOTPUT /api/userPATCH /api/users/:id/password— change password ·DELETE /api/users/:id— delete accountPATCH /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/querysubscriptionId); 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. 404user.payment.noBillingAccountwhen the user has no customer record yetGET /api/users/me— the current user (session restore) ·GET /api/users/:id— read one The full, authoritative route list is theroutesexport (seeroutes.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_activitytool (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.