@molecule/api-resource-device
API resource · auth · API (Node) · v1.0.8 · Apache-2.0
Device resource types and schema for molecule.dev.
npm install @molecule/api-resource-deviceHow it works
@molecule/api-resource-device 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.
import { createRequestHandler } from '@molecule/api-resource'
import { createRequestHandlerMap, resource, routes } from '@molecule/api-resource-device'
// Unlike newer resources, the handler map is a FACTORY — build it with the
// createRequestHandler from @molecule/api-resource (mlcl inject does this):
const requestHandlerMap = createRequestHandlerMap(createRequestHandler)Works with: @molecule/api-bond, @molecule/api-database, @molecule/api-i18n, @molecule/api-locales-device, @molecule/api-resource
Reference
The device resource for molecule.dev — one row per signed-in browser or
app install, owned by a user. Devices anchor session context and push
delivery: the row stores pushPlatform/pushSubscription, the routes cover
device list/read/update/delete plus GET /devices/push/public-key (the
VAPID key browsers need to subscribe), and API-side push fan-outs read the
stored subscriptions.
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.read(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.
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
import { createRequestHandler } from '@molecule/api-resource'
import { createRequestHandlerMap, resource, routes } from '@molecule/api-resource-device'
// Unlike newer resources, the handler map is a FACTORY — build it with the
// createRequestHandler from @molecule/api-resource (mlcl inject does this):
const requestHandlerMap = createRequestHandlerMap(createRequestHandler)
Type
resource
Installation
npm install @molecule/api-resource-device @molecule/api-bond @molecule/api-database @molecule/api-i18n @molecule/api-locales-device @molecule/api-resource uuid zod
API
Interfaces
DeviceRequestHandlerMap
Shape of the device request-handler map produced by createRequestHandlerMap.
Names match the route definitions in routes.ts. Exported so helpers that
accept the map (e.g. mountDefaultDeviceRoutes) can type their parameter
precisely instead of widening to Record<string, MoleculeRequestHandler>.
interface DeviceRequestHandlerMap {
auth: MoleculeRequestHandler
authUser: MoleculeRequestHandler
del: MoleculeRequestHandler
pushPublicKey: MoleculeRequestHandler
query: MoleculeRequestHandler
read: MoleculeRequestHandler
update: MoleculeRequestHandler
}
DeviceService
DeviceService interface for the bond system.
Provides device CRUD operations that other resources
can use through get<DeviceService>('device').
interface DeviceService {
createOrUpdate(userId: string, deviceName: string): Promise<string | null>
updateLastSeen(deviceId: string): Promise<void>
deleteByUserId(userId: string): Promise<void>
/**
* Whether a device row still exists. Used by the user resource's session
* authorization to enforce server-side revocation: a deleted device (logout,
* remote device removal, password-reset invalidation) makes this return
* `false`, so the JWT bound to it is rejected for every copy of the token.
* @param deviceId - The device id to check.
* @returns `true` if the device exists, `false` if it was deleted/never existed.
*/
exists(deviceId: string): Promise<boolean>
/**
* Delete a single device row by id, terminating that session. Used by the
* logout handler and remote device-revocation route. Combined with the
* `exists`-based revocation check in session authorization, this makes a
* logout effective for every copy of the device's JWT.
* @param deviceId - The device id to delete.
*/
delete(deviceId: string): Promise<void>
getWithPushSubscription(
userId: string,
): Promise<Array<{ id: string; pushPlatform: string; pushSubscription: unknown }>>
}
Types
Props
Full device record properties (userId, name, push platform/subscription, timestamps).
type Props = z.infer<typeof propsSchema>
PushSubscription
Web Push subscription type.
type PushSubscription = z.infer<typeof webPushSubscriptionSchema>
FCMPushSubscription
FCM push subscription type.
type FCMPushSubscription = z.infer<typeof fcmPushSubscriptionSchema>
APNPushSubscription
APN push subscription type.
type APNPushSubscription = z.infer<typeof apnPushSubscriptionSchema>
CreateProps
Fields required when registering a new device (userId, name, push subscription).
type CreateProps = z.infer<typeof createPropsSchema>
UpdateProps
Updatable device fields (name, push platform, push subscription).
type UpdateProps = z.infer<typeof updatePropsSchema>
PushProps
Push notification properties for a device (device ID, platform, subscription data).
type PushProps = z.infer<typeof pushPropsSchema>
Resource
An object describing the device resource.
type Resource<T = unknown> = resourceTypes.Resource<T>
Functions
createRequestHandlerMap(createRequestHandler)
Creates the full request handler map for the Device resource. Maps handler names (matching
route definitions) to Express middleware: auth, authUser (authorizers), and del, query,
read, update (CRUD handlers).
function createRequestHandlerMap(
createRequestHandler: (
handler: Handler,
) => (req: MoleculeRequest, res: MoleculeResponse, next: MoleculeNextFunction) => Promise<void>,
): DeviceRequestHandlerMap
createRequestHandler— Factory from@molecule/api-resourcethat wraps handler configs into Express middleware.
Returns: A DeviceRequestHandlerMap of handler names to Express middleware.
Constants
i18nRegistered
The i18n registered.
const i18nRegistered: true
resource
The device resource definition.
const resource: types.Resource<unknown>
routes
Declarative route definitions for the Device resource, used to generate the Express router.
const routes: (
| { method: 'get'; path: string; middlewares: string[]; handler: string }
| { method: 'patch'; path: string; middlewares: string[]; handler: string }
| { method: 'delete'; path: string; middlewares: string[]; handler: string }
)[]
pushSubscriptionKeysSchema
Web Push subscription keys schema.
const pushSubscriptionKeysSchema: z.ZodObject<
{ p256dh: z.ZodString; auth: z.ZodString },
z.core.$strip
>
webPushSubscriptionSchema
Web Push subscription schema.
const webPushSubscriptionSchema: z.ZodObject<
{
endpoint: z.ZodOptional<z.ZodString>
keys: z.ZodOptional<z.ZodObject<{ p256dh: z.ZodString; auth: z.ZodString }, z.core.$strip>>
},
z.core.$strip
>
fcmPushSubscriptionSchema
FCM push subscription schema.
const fcmPushSubscriptionSchema: z.ZodObject<
{ registrationId: z.ZodString; registrationType: z.ZodLiteral<'FCM'> },
z.core.$strip
>
apnPushSubscriptionSchema
APN push subscription schema.
const apnPushSubscriptionSchema: z.ZodObject<{ registrationId: z.ZodString }, z.core.$strip>
pushSubscriptionSchema
Combined push subscription schema (union of all types).
const pushSubscriptionSchema: z.ZodOptional<
z.ZodNullable<
z.ZodUnion<
readonly [
z.ZodObject<
{
endpoint: z.ZodOptional<z.ZodString>
keys: z.ZodOptional<
z.ZodObject<{ p256dh: z.ZodString; auth: z.ZodString }, z.core.$strip>
>
},
z.core.$strip
>,
z.ZodObject<
{ registrationId: z.ZodString; registrationType: z.ZodLiteral<'FCM'> },
z.core.$strip
>,
z.ZodObject<{ registrationId: z.ZodString }, z.core.$strip>,
]
>
>
>
propsSchema
The full schema for device props.
const propsSchema: z.ZodObject<
{
id: z.ZodString
createdAt: z.ZodString
updatedAt: z.ZodString
userId: z.ZodString
name: z.ZodOptional<z.ZodString>
pushPlatform: z.ZodOptional<z.ZodEnum<{ fcm: 'fcm'; apn: 'apn' }>>
pushSubscription: z.ZodOptional<
z.ZodNullable<
z.ZodUnion<
readonly [
z.ZodObject<
{
endpoint: z.ZodOptional<z.ZodString>
keys: z.ZodOptional<
z.ZodObject<{ p256dh: z.ZodString; auth: z.ZodString }, z.core.$strip>
>
},
z.core.$strip
>,
z.ZodObject<
{ registrationId: z.ZodString; registrationType: z.ZodLiteral<'FCM'> },
z.core.$strip
>,
z.ZodObject<{ registrationId: z.ZodString }, z.core.$strip>,
]
>
>
>
hasPushSubscription: z.ZodOptional<z.ZodBoolean>
},
z.core.$strip
>
createPropsSchema
Schema for creating a device.
const createPropsSchema: z.ZodObject<
{
userId: z.ZodString
name: z.ZodOptional<z.ZodString>
pushPlatform: z.ZodOptional<z.ZodEnum<{ fcm: 'fcm'; apn: 'apn' }>>
pushSubscription: z.ZodOptional<
z.ZodNullable<
z.ZodUnion<
readonly [
z.ZodObject<
{
endpoint: z.ZodOptional<z.ZodString>
keys: z.ZodOptional<
z.ZodObject<{ p256dh: z.ZodString; auth: z.ZodString }, z.core.$strip>
>
},
z.core.$strip
>,
z.ZodObject<
{ registrationId: z.ZodString; registrationType: z.ZodLiteral<'FCM'> },
z.core.$strip
>,
z.ZodObject<{ registrationId: z.ZodString }, z.core.$strip>,
]
>
>
>
hasPushSubscription: z.ZodOptional<z.ZodBoolean>
},
z.core.$strip
>
updatePropsSchema
Zod schema for updating a device (partial pick of name, push platform, push subscription).
const updatePropsSchema: z.ZodObject<
{
name: z.ZodOptional<z.ZodOptional<z.ZodString>>
pushPlatform: z.ZodOptional<z.ZodOptional<z.ZodEnum<{ fcm: 'fcm'; apn: 'apn' }>>>
pushSubscription: z.ZodOptional<
z.ZodOptional<
z.ZodNullable<
z.ZodUnion<
readonly [
z.ZodObject<
{
endpoint: z.ZodOptional<z.ZodString>
keys: z.ZodOptional<
z.ZodObject<{ p256dh: z.ZodString; auth: z.ZodString }, z.core.$strip>
>
},
z.core.$strip
>,
z.ZodObject<
{ registrationId: z.ZodString; registrationType: z.ZodLiteral<'FCM'> },
z.core.$strip
>,
z.ZodObject<{ registrationId: z.ZodString }, z.core.$strip>,
]
>
>
>
>
hasPushSubscription: z.ZodOptional<z.ZodOptional<z.ZodBoolean>>
},
z.core.$strip
>
pushPropsSchema
Zod schema for push notification properties (device ID, platform, subscription).
const pushPropsSchema: z.ZodObject<
{
id: z.ZodString
pushPlatform: z.ZodOptional<z.ZodEnum<{ fcm: 'fcm'; apn: 'apn' }>>
pushSubscription: z.ZodOptional<
z.ZodNullable<
z.ZodUnion<
readonly [
z.ZodObject<
{
endpoint: z.ZodOptional<z.ZodString>
keys: z.ZodOptional<
z.ZodObject<{ p256dh: z.ZodString; auth: z.ZodString }, z.core.$strip>
>
},
z.core.$strip
>,
z.ZodObject<
{ registrationId: z.ZodString; registrationType: z.ZodLiteral<'FCM'> },
z.core.$strip
>,
z.ZodObject<{ registrationId: z.ZodString }, z.core.$strip>,
]
>
>
>
},
z.core.$strip
>
deviceService
DeviceService implementation for the bond system.
Provides device CRUD operations that other resources
can use through get('device') / require('device').
const deviceService: DeviceService
Namespaces
z
Re-exports the zod module — z.<name> is exactly zod.<name>, documented by that package (installed at node_modules/zod).
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.authUser(resource)
Middleware that verifies the device belongs to the authenticated user. Queries the database
for a device matching both the :id route parameter and session.userId. On success, stores
the device props in res.locals.device for downstream handlers and calls next().
function authUser({ tableName }: { tableName: string }): MoleculeRequestHandler
resource— The device resource configuration (tableName).resource.tableName— The database table name for devices.
Returns: An Express-compatible middleware function.
handlers
Re-exported as a namespace — reach every member below through it (e.g. handlers.<name>).
handlers.del(resource)
Deletes a device by ID from the database.
function del({
name: _name,
tableName,
}: {
name: string
tableName: string
}): (
req: MoleculeRequest,
) => Promise<
| { statusCode: number; body: { error: string; errorKey: string; props?: undefined } }
| { statusCode: number; body: { props: { id: string }; error?: undefined; errorKey?: undefined } }
>
resource— The device resource configuration (name, tableName).resource.name— The resource name.resource.tableName— The database table name for devices.
Returns: A request handler that responds with { statusCode: 200, body: { props: { id } } } on success.
handlers.pushPublicKey()
Push public key — GET /devices/push/public-key.
Returns the server's VAPID public key so browsers can create a push
subscription with applicationServerKey (Chromium rejects keyless
subscriptions). The client then stores the resulting subscription on its
device row via PATCH /devices/:id { pushSubscription, hasPushSubscription }
— the contract the api-side push fan-outs read.
The key is public by design (it ships inside every subscription request the browser makes to the push service), so the route needs no auth.
Bond-gated like oauthAuthorize in @molecule/api-resource-user: provider
knowledge stays in the bond — the handler asks the bonded push-notifications
provider (get('push-notifications')) for its getPublicKey(). A provider
that is not bonded yields a clean 404; one that is bonded but unconfigured
(no VAPID_PUBLIC_KEY) yields a 503 — never a crash.
function pushPublicKey(): () => Promise<
| { statusCode: number; body: { error: string; errorKey: string; publicKey?: undefined } }
| { statusCode: number; body: { publicKey: string; error?: undefined; errorKey?: undefined } }
>
Returns: A request handler that responds 200 with { publicKey }.
handlers.query(resource)
Queries all devices belonging to the authenticated user. Supports pagination via before/after
cursor filters on createdAt/updatedAt, configurable limit (1–500, default 100), and
orderBy/orderDirection params. Sorts the current session's device to the front of results
and marks it with isCurrent: true.
function query({ tableName }: { tableName: string }): (
req: MoleculeRequest,
res: MoleculeResponse,
) => Promise<
| { statusCode: number; body: { error: string; errorKey: string } }
| {
statusCode: number
body: ({
id: string
createdAt: string
updatedAt: string
userId: string
name?: string | undefined
pushPlatform?: 'fcm' | 'apn' | undefined
pushSubscription?:
| { endpoint?: string | undefined; keys?: { p256dh: string; auth: string } | undefined }
| { registrationId: string; registrationType: 'FCM' }
| { registrationId: string }
| null
| undefined
hasPushSubscription?: boolean | undefined
} & { isCurrent?: boolean })[]
}
>
resource— The device resource configuration (tableName).resource.tableName— The database table name for devices.
Returns: A request handler that responds with an array of device props.
handlers.read(resource)
Reads a single device from res.locals.device, which is set by the authUser authorizer
middleware. Returns 404 if the device was not found during authorization.
function read({
tableName: _tableName,
}: {
tableName: string
}): (
_req: MoleculeRequest,
res: MoleculeResponse,
) =>
| { statusCode: number; body: { props: any; error?: undefined; errorKey?: undefined } }
| { statusCode: number; body: { error: string; errorKey: string; props?: undefined } }
resource— The device resource configuration (tableName).resource.tableName— The database table name for devices.
Returns: A request handler that responds with { statusCode: 200, body: { props } }.
handlers.update(resource)
Updates a device's allowed fields: name, pushPlatform, pushSubscription, and
hasPushSubscription. Automatically sets updatedAt to the current timestamp.
function update({ name: _name, tableName }: { name: string; tableName: string }): (
req: MoleculeRequest,
) => Promise<
| { statusCode: number; body: { error: string; errorKey: string; props?: undefined } }
| {
statusCode: number
body: { props: Record<string, unknown>; error?: undefined; errorKey?: undefined }
}
>
resource— The device resource configuration (name, tableName).resource.name— The resource name.resource.tableName— The database table name for devices.
Returns: A request handler that responds with the updated props on success.
types
Re-exported as a namespace — reach every member below through it (e.g. types.<name>).
types.Props
Full device record properties (userId, name, push platform/subscription, timestamps).
type Props = z.infer<typeof propsSchema>
types.PushSubscription
Web Push subscription type.
type PushSubscription = z.infer<typeof webPushSubscriptionSchema>
types.FCMPushSubscription
FCM push subscription type.
type FCMPushSubscription = z.infer<typeof fcmPushSubscriptionSchema>
types.APNPushSubscription
APN push subscription type.
type APNPushSubscription = z.infer<typeof apnPushSubscriptionSchema>
types.CreateProps
Fields required when registering a new device (userId, name, push subscription).
type CreateProps = z.infer<typeof createPropsSchema>
types.UpdateProps
Updatable device fields (name, push platform, push subscription).
type UpdateProps = z.infer<typeof updatePropsSchema>
types.PushProps
Push notification properties for a device (device ID, platform, subscription data).
type PushProps = z.infer<typeof pushPropsSchema>
types.Resource
An object describing the device resource.
type Resource<T = unknown> = resourceTypes.Resource<T>
types.DeviceService
DeviceService interface for the bond system.
Provides device CRUD operations that other resources
can use through get<DeviceService>('device').
interface DeviceService {
createOrUpdate(userId: string, deviceName: string): Promise<string | null>
updateLastSeen(deviceId: string): Promise<void>
deleteByUserId(userId: string): Promise<void>
/**
* Whether a device row still exists. Used by the user resource's session
* authorization to enforce server-side revocation: a deleted device (logout,
* remote device removal, password-reset invalidation) makes this return
* `false`, so the JWT bound to it is rejected for every copy of the token.
* @param deviceId - The device id to check.
* @returns `true` if the device exists, `false` if it was deleted/never existed.
*/
exists(deviceId: string): Promise<boolean>
/**
* Delete a single device row by id, terminating that session. Used by the
* logout handler and remote device-revocation route. Combined with the
* `exists`-based revocation check in session authorization, this makes a
* logout effective for every copy of the device's JWT.
* @param deviceId - The device id to delete.
*/
delete(deviceId: string): Promise<void>
getWithPushSubscription(
userId: string,
): Promise<Array<{ id: string; pushPlatform: string; pushSubscription: unknown }>>
}
Services
This package exports services that should be registered with the bond system:
import { bond } from '@molecule/api-bond'
import { deviceService } from '@molecule/api-resource-device'
bond('device', deviceService)
Injection Notes
Requirements
Peer dependencies:
@molecule/api-bond^1.0.1@molecule/api-i18n^1.0.1@molecule/api-locales-device^1.0.1@molecule/api-resource^1.0.1@molecule/api-database^1.0.1
Runtime Dependencies
-
@molecule/api-bond -
@molecule/api-database -
@molecule/api-i18n -
@molecule/api-locales-device -
@molecule/api-resource -
uuid -
zod -
Table setup:
src/__setup__/devices.sqlships with this package (the standard scaffold replays__setup__/*.sqlon migrate;resource.tableNameisdevices). When adding to an existing app, apply it before use — nothing at runtime creates the table. -
The handler map is a factory, not a constant. Call
createRequestHandlerMap(createRequestHandler); do not import arequestHandlerMapconstant like other resources export. Itsauth/authUserentries are the authorizer middlewares the routes reference —authUserenforces that the session user OWNS the device row; removing either from the map ships the routes ungated (IDOR on other users' devices). -
Device rows are created by the auth flow, not by these routes — there is no
POST /devices; signup/login registers the caller's device. Clients then save their push subscription viaPATCH /devices/:id { pushSubscription, hasPushSubscription }— the contract the push fan-outs read. -
GET /devices/push/public-keyis unauthenticated by design (VAPID public keys are public) and bond-gated: 404 when nopush-notificationsbond is wired, 503 when it is wired but unconfigured (VAPID_PUBLIC_KEYunset) — never a crash. Surface those states in the client instead of retrying.
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:
- Registering a device persists a real row that appears in the signed-in
user's device list: sign in on a fresh browser/install, open GET /devices,
and the device shows with its
nameand timestamps — the current session's device sorted first and flaggedisCurrent: true. Rows are created by the AUTH flow (signup/login →createOrUpdate), NOT a POST route; there is noPOST /devices. - Re-registering the SAME device does not duplicate it: sign in again from
the same device (same user + device
name) and the existing row is reused with a bumpedupdatedAt— the list count is unchanged, no second row appears. (The dedup key here is user +name, not a push token.) - Saving a push subscription targets THIS device and only opted-in ones:
PATCH /devices/:id { pushSubscription, pushPlatform, hasPushSubscription:
true } stores the subscription (
pushSubscriptionis what push actually targets), and a real push fan-out reaches exactly the user's devices wherehasPushSubscriptionis true (getWithPushSubscription) — a device that never subscribed receives nothing. - Refreshing the subscription replaces the stale one: PATCH the same
device with a new
pushSubscriptionand the next push goes to the NEW value, never the old — one current subscription per device, not a growing list. Clearing it (hasPushSubscription: false) drops the device from the fan-out set immediately, so it stops receiving. - Last-seen tracks use: an active device's
updatedAtadvances on re-registration /updateLastSeen, so the list reflects recency. - Removing a device deletes it and it can no longer be targeted OR used:
DELETE /devices/:id removes the row, so it disappears from GET /devices, is
excluded from every push fan-out, AND its session is revoked — the JWT bound
to that
deviceIdis rejected on its very next request (exists()→ false), for every copy of the token. - AUTHORIZATION — devices are strictly per-user. GET /devices returns ONLY
the session user's own rows; reading, updating, or deleting via /devices/:id
is gated by
authUser, which requires the deviceidAND itsuserIdto match the session — so guessing another user's device id is rejected (401, no IDOR into their row or push subscription). The owner is always the session user: a device is registered under the caller's ownuserId, and one user can never PATCH a push subscription onto another user's device.