@molecule/api-resource-device
API resource · auth · API (Node) · v1.0.4 · 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.
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
}
Types
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>
FCMPushSubscription
FCM push subscription type.
type FCMPushSubscription = z.infer<typeof fcmPushSubscriptionSchema>
Props
Full device record properties (userId, name, push platform/subscription, timestamps).
type Props = z.infer<typeof propsSchema>
PushProps
Push notification properties for a device (device ID, platform, subscription data).
type PushProps = z.infer<typeof pushPropsSchema>
PushSubscription
Web Push subscription type.
type PushSubscription = z.infer<typeof webPushSubscriptionSchema>
UpdateProps
Updatable device fields (name, push platform, push subscription).
type UpdateProps = z.infer<typeof updatePropsSchema>
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
apnPushSubscriptionSchema
APN push subscription schema.
const apnPushSubscriptionSchema: z.ZodObject<{ registrationId: z.ZodString }, 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
>
deviceService
DeviceService implementation for the bond system.
Provides device CRUD operations that other resources
can use through get('device') / require('device').
const deviceService: DeviceService
fcmPushSubscriptionSchema
FCM push subscription schema.
const fcmPushSubscriptionSchema: z.ZodObject<
{ registrationId: z.ZodString; registrationType: z.ZodLiteral<'FCM'> },
z.core.$strip
>
i18nRegistered
The i18n registered.
const i18nRegistered: true
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
>
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
>
pushSubscriptionKeysSchema
Web Push subscription keys schema.
const pushSubscriptionKeysSchema: z.ZodObject<
{ p256dh: z.ZodString; auth: 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>,
]
>
>
>
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 }
)[]
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
>
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
>
Namespaces
authorizers
Members:
authorizers.auth— function: Middleware that checks if the request has an authenticated session (res.locals.session.userId).authorizers.authUser— function: Middleware that verifies the device belongs to the authenticated user. Queries the database
handlers
Members:
handlers.del— function: Deletes a device by ID from the database.handlers.pushPublicKey— function: Push public key —GET /devices/push/public-key.handlers.query— function: Queries all devices belonging to the authenticated user. Supports pagination viabefore/afterhandlers.read— function: Reads a single device fromres.locals.device, which is set by theauthUserauthorizerhandlers.update— function: Updates a device's allowed fields:name,pushPlatform,pushSubscription, and
types
Members:
types.APNPushSubscription— type: APN push subscription type.types.CreateProps— type: Fields required when registering a new device (userId, name, push subscription).types.FCMPushSubscription— type: FCM push subscription type.types.Props— type: Full device record properties (userId, name, push platform/subscription, timestamps).types.PushProps— type: Push notification properties for a device (device ID, platform, subscription data).types.PushSubscription— type: Web Push subscription type.types.UpdateProps— type: Updatable device fields (name, push platform, push subscription).types.Resource— type: An object describing thedeviceresource.types.DeviceService— interface: DeviceService interface for the bond system.
z
Members:
z.core— namespacez.infer— typez.output— typez.input— typez.JSONType— typez.globalRegistry— constz.GlobalMeta— interfacez.registry— functionz.config— functionz.$output— constz.$input— constz.$brand— constz.clone— functionz.regexes— namespacez.treeifyError— functionz.prettifyError— functionz.formatError— functionz.flattenError— functionz.TimePrecision— constz.util— namespacez.NEVER— const: A special constant with typeneverz.toJSONSchema— functionz.fromJSONSchema— function: Converts a JSON Schema to a Zod schema. This function should be considered semi-experimental. It's behavior is liable to change.z.locales— namespacez.ZodISODateTime— interfacez.ZodISODate— interfacez.ZodISOTime— interfacez.ZodISODuration— interfacez.iso— namespacez.ZodCoercedString— interfacez.ZodCoercedNumber— interfacez.ZodCoercedBigInt— interfacez.ZodCoercedBoolean— interfacez.ZodCoercedDate— interfacez.coerce— namespacez.string— functionz.email— functionz.guid— functionz.uuid— functionz.uuidv4— functionz.uuidv6— functionz.uuidv7— functionz.url— functionz.httpUrl— functionz.emoji— functionz.nanoid— functionz.cuid— function: Validates a CUID v1 string.z.cuid2— functionz.ulid— functionz.xid— functionz.ksuid— functionz.ipv4— functionz.mac— functionz.ipv6— functionz.cidrv4— functionz.cidrv6— functionz.base64— functionz.base64url— functionz.e164— functionz.jwt— functionz.stringFormat— functionz.hostname— functionz.hex— functionz.hash— functionz.number— functionz.int— functionz.float32— functionz.float64— functionz.int32— functionz.uint32— functionz.boolean— functionz.bigint— functionz.int64— functionz.uint64— functionz.symbol— functionz.any— functionz.unknown— functionz.never— functionz.date— functionz.array— functionz.keyof— functionz.object— functionz.strictObject— functionz.looseObject— functionz.union— functionz.xor— function: Creates an exclusive union (XOR) where exactly one option must match.z.discriminatedUnion— functionz.intersection— functionz.tuple— functionz.record— functionz.partialRecord— functionz.looseRecord— functionz.map— functionz.set— functionz.nativeEnum— functionz.literal— functionz.file— functionz.transform— functionz.optional— functionz.exactOptional— functionz.nullable— functionz.nullish— functionz._default— functionz.prefault— functionz.nonoptional— functionz.success— functionz.nan— functionz.pipe— functionz.codec— functionz.invertCodec— functionz.readonly— functionz.templateLiteral— functionz.lazy— functionz.promise— functionz._function— functionz.check— functionz.custom— functionz.refine— functionz.superRefine— functionz.json— functionz.preprocess— functionz.ZodStandardSchemaWithJSON— typez.ZodType— interfacez._ZodType— interfacez._ZodString— interfacez.ZodString— interfacez.ZodStringFormat— interfacez.ZodEmail— interfacez.ZodGUID— interfacez.ZodUUID— interfacez.ZodURL— interfacez.ZodEmoji— interfacez.ZodNanoID— interfacez.ZodCUID— interfacez.ZodCUID2— interfacez.ZodULID— interfacez.ZodXID— interfacez.ZodKSUID— interfacez.ZodIPv4— interfacez.ZodMAC— interfacez.ZodIPv6— interfacez.ZodCIDRv4— interfacez.ZodCIDRv6— interfacez.ZodBase64— interfacez.ZodBase64URL— interfacez.ZodE164— interfacez.ZodJWT— interfacez.ZodCustomStringFormat— interfacez._ZodNumber— interfacez.ZodNumber— interfacez.ZodNumberFormat— interfacez.ZodInt— interfacez.ZodFloat32— interfacez.ZodFloat64— interfacez.ZodInt32— interfacez.ZodUInt32— interfacez._ZodBoolean— interfacez.ZodBoolean— interfacez._ZodBigInt— interfacez.ZodBigInt— interfacez.ZodBigIntFormat— interfacez.ZodSymbol— interfacez.ZodUndefined— interfacez.undefined— functionz.ZodNull— interfacez.null— functionz.ZodAny— interfacez.ZodUnknown— interfacez.ZodNever— interfacez.ZodVoid— interfacez.void— functionz._ZodDate— interfacez.ZodDate— interfacez.ZodArray— interfacez.SafeExtendShape— typez.ZodObject— interfacez.ZodUnion— interfacez.ZodXor— interfacez.ZodDiscriminatedUnion— interfacez.ZodIntersection— interfacez.ZodTuple— interfacez.ZodRecord— interfacez.ZodMap— interfacez.ZodSet— interfacez.ZodEnum— interfacez.enum— functionz.ZodLiteral— interfacez.ZodFile— interfacez.ZodTransform— interfacez.ZodOptional— interfacez.ZodExactOptional— interfacez.ZodNullable— interfacez.ZodDefault— interfacez.ZodPrefault— interfacez.ZodNonOptional— interfacez.ZodSuccess— interfacez.ZodCatch— interfacez.catch— functionz.ZodNaN— interfacez.ZodPipe— interfacez.ZodCodec— interfacez.ZodPreprocess— interfacez.ZodReadonly— interfacez.ZodTemplateLiteral— interfacez.ZodLazy— interfacez.ZodPromise— interfacez.ZodFunction— interfacez.function— functionz.ZodCustom— interfacez.describe— constz.meta— constz.instanceof— functionz.stringbool— constz.ZodJSONSchemaInternals— interfacez.ZodJSONSchema— interfacez.lt— functionz.lte— functionz.gt— functionz.gte— functionz.positive— functionz.negative— functionz.nonpositive— functionz.nonnegative— functionz.multipleOf— functionz.maxSize— functionz.minSize— functionz.size— functionz.maxLength— functionz.minLength— functionz.length— functionz.regex— functionz.lowercase— functionz.uppercase— functionz.includes— functionz.startsWith— functionz.endsWith— functionz.property— functionz.mime— functionz.overwrite— functionz.normalize— functionz.trim— functionz.toLowerCase— functionz.toUpperCase— functionz.slugify— functionz.RefinementCtx— interfacez.ZodIssue— typez.ZodError— interface: An Error-like class used to store Zod validation issues.z.ZodRealError— constz.ZodFlattenedError— typez.ZodFormattedError— typez.ZodErrorMap— interfacez.IssueData— typez.ZodSafeParseResult— typez.ZodSafeParseSuccess— typez.ZodSafeParseError— typez.parse— constz.parseAsync— constz.safeParse— constz.safeParseAsync— constz.encode— constz.decode— constz.encodeAsync— constz.decodeAsync— constz.safeEncode— constz.safeDecode— constz.safeEncodeAsync— constz.safeDecodeAsync— constz.setErrorMap— functionz.getErrorMap— functionz.TypeOf— typez.Infer— typez.ZodFirstPartySchemaTypes— typez.ZodIssueCode— constz.inferFlattenedErrors— typez.inferFormattedError— typez.BRAND— type: Usez.$brandinsteadz.ZodTypeAny— interfacez.ZodSchema— interfacez.Schema— interfacez.ZodRawShape— type: Included for Zod 3 compatibilityz.ZodFirstPartyTypeKind— enum
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:
setup/devices.sqlships with this package (the standard scaffold applies it as a base migration;resource.tableNameisdevices). When adding to an existing app, apply it before use. -
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.