@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-device

npm · Source on GitHub

How 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.ts JSDoc, 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-resource that 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.sql ships with this package (the standard scaffold replays __setup__/*.sql on migrate; resource.tableName is devices). 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 a requestHandlerMap constant like other resources export. Its auth / authUser entries are the authorizer middlewares the routes reference — authUser enforces 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 via PATCH /devices/:id { pushSubscription, hasPushSubscription } — the contract the push fan-outs read.

  • GET /devices/push/public-key is unauthenticated by design (VAPID public keys are public) and bond-gated: 404 when no push-notifications bond is wired, 503 when it is wired but unconfigured (VAPID_PUBLIC_KEY unset) — 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 name and timestamps — the current session's device sorted first and flagged isCurrent: true. Rows are created by the AUTH flow (signup/login → createOrUpdate), NOT a POST route; there is no POST /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 bumped updatedAt — 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 (pushSubscription is what push actually targets), and a real push fan-out reaches exactly the user's devices where hasPushSubscription is true (getWithPushSubscription) — a device that never subscribed receives nothing.
  • Refreshing the subscription replaces the stale one: PATCH the same device with a new pushSubscription and 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 updatedAt advances 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 deviceId is 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 device id AND its userId to 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 own userId, and one user can never PATCH a push subscription onto another user's device.