npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@frozencrow/api-core

v0.3.1

Published

Batteries-included Feathers 5 + Koa + MongoDB core: auth, RBAC, and multitenant access control

Readme

@frozencrow/api-core

A batteries-included FeathersJS 5 + Koa + MongoDB core for building multitenant APIs. It ships authentication, a repeatable service + hook generation system, role-based access control (RBAC), and per-organization data isolation, so you can focus on your own resources.

Want a whole project instead of wiring it up yourself? Use the scaffolder: npm create @frozencrow/api my-app (@frozencrow/create-api).

Contents

Features

  • Auth — local (email/password), JWT, Google OAuth (redirect + One Tap), static API keys, and anonymous, all pre-registered.
  • Servicesusers, organizations, roles, invites, verifications, serial-ids, out of the box.
  • RBAC — permissions of the form <path>:<method>, <path>:*, or *, resolved from a user's role within their active organization.
  • Multitenancy — every request is automatically scoped to the caller's activeOrganization; cross-tenant reads/writes are rejected.
  • Repeatable services — one generateDefaultHooks({ schema }) call wires a new resource with auth, tenant scoping, validation, resolvers, and logging.
  • Email — nodemailer + doT templates (welcome, invitation, password reset, magic link) with a layout system and consumer overrides.
  • Safe by default — refuses to boot in production with a weak JWT secret, restricts CORS, hides secrets from API output, and scopes realtime events.

Installation

npm install @frozencrow/api-core

Peer runtime: Node >= 20 and a reachable MongoDB.

Quick start

The recommended setup uses the Feathers config module — configuration lives in config/*.json, secrets in a git-ignored config/local.json, and production overrides come from environment variables mapped in config/custom-environment-variables.json. No .env files.

import { createConfiguredApp } from '@frozencrow/api-core'

const app = createConfiguredApp() // reads ./config via @feathersjs/configuration

// Register your own services on top of the core:
app.configure(myServices)

await app.listen(app.get('port'))

config/default.json:

{
  "appName": "My API",
  "host": "localhost",
  "port": 3030,
  "public": "./public",
  "origins": ["http://localhost:5173"],
  "seed": true,
  "paginate": { "default": 10, "max": 50 },
  "mongodb": "mongodb://localhost:27017/my-api",
  "authentication": {
    "entity": "user", "service": "users", "secret": "CHANGE_ME_IN_PRODUCTION",
    "authStrategies": ["jwt", "local"],
    "jwtOptions": { "header": { "typ": "access" }, "audience": "https://example.com", "algorithm": "HS256", "expiresIn": "1d" },
    "local": { "usernameField": "email", "passwordField": "password" }
  }
}

The config schema (configurationValidator) allows extra keys, so you can add your own configuration freely. Pass a custom validator to createConfiguredApp(validator) if you want to type/validate those additions.

Alternative: configure programmatically

If you'd rather not use config files, createApp(options) sets the same config keys from a CoreOptions object:

import { createApp } from '@frozencrow/api-core'

const app = createApp({
  mongodb: process.env.MONGODB_URI!,
  authSecret: process.env.AUTH_SECRET!,
  origins: ['http://localhost:5173'],
  seed: true
})

Both paths end up with the same app.get(...) config; configureCore(app) (used by both) reads from there. Neither calls listen for you.

Configuration (CoreOptions)

These are the keys createApp accepts; they map 1:1 onto the config-module keys above. Only mongodb and authSecret are required.

| Option | Type | Default | Notes | | --- | --- | --- | --- | | mongodb | string | — | MongoDB connection string (the db name is taken from the path) | | authSecret | string | — | JWT signing secret; must be strong in production | | appName | string | 'API' | | | host | string | 'localhost' | | | port | number | 3030 | | | public | string | './public' | static files dir; set falsy to disable | | apiHost | string | — | absolute base URL of this API (OAuth callbacks) | | clientHost | string | — | frontend base URL (used in email links) | | origins | string[] | [] | CORS allow-list (REST + socket.io) | | paginate | { default, max } | { 10, 50 } | | | jwtOptions | object | 1d, HS256 | merged into Feathers authentication.jwtOptions | | apiKeys | string[] | — | accepted by the api-key strategy | | oauth | object | — | { google: { key, secret, scope? }, redirect? } | | authentication | object | — | deep overrides merged last into the resolved auth config | | mail | object | — | { host, port, secure?, auth?, from?, fromName? } | | seed | boolean \| { roles } | off | true seeds Admin/Member; pass { roles } to customize | | templatesDir | string | bundled | directory of .dot templates to use instead of the built-ins | | defaultOrgName | (user) => string | "…'s Organization" | names the org auto-created for a new user | | services | CoreServiceName[] | all | subset of built-in services to register | | channels | ChannelsOptions \| fn \| false | tenant-scoped | extend, replace, or disable realtime channels (see Realtime events) | | extend | Record<string, ServiceExtension> | — | add fields/resolvers/hooks to built-in services (see Extending built-in services) |

Anything you set is written onto Feathers config, so services read it via app.get('appName'), app.get('paginate'), etc.

Built-in services

| Path | Purpose | Notable methods | | --- | --- | --- | | users | Accounts; restrictToUser (a user only sees themselves externally). Roles are populated into objects on read. | find, get, create, patch, remove, impersonate | | organizations | Tenants. Holds a members[] list of { userId, role, joinedAt }, an ownerId, and onboarded. | standard + invite, removeMember, updateMemberRole, getMembers | | roles | Named permission sets ({ name, permissions[] }). World-readable. | standard | | invites | Organization invitations (email or link), backed by a verification token. | standard | | verifications | Secret tokens for password reset / magic link / invite. External reads are blocked. | create, patch | | serial-ids | Atomic per-org counters behind generateShortId. | standard + next |

A newly created user with no organization is automatically given one (as its Admin) on first authentication — see ensureUserHasOrganization. Customize the name via defaultOrgName.

Authentication

The following strategies are registered: jwt, local, google, google-one-tap, api-key, anonymous. By default authStrategies is ['jwt', 'local']; to allow additional login strategies, extend it:

createApp({
  mongodb, authSecret,
  apiKeys: ['service-key-123'],
  oauth: { google: { key: GOOGLE_CLIENT_ID, secret: GOOGLE_CLIENT_SECRET } },
  authentication: { authStrategies: ['jwt', 'local', 'api-key', 'google'] }
})

Email + password login (REST):

POST /authentication
{ "strategy": "local", "email": "[email protected]", "password": "secret" }

Returns { accessToken, user }. Send the token on later requests as Authorization: Bearer <accessToken>. Passwords are always stripped from responses.

API key — send Authorization: <one-of-apiKeys> and authenticate with the api-key strategy. Google One TapPOST /authentication { strategy: 'google-one-tap', credential }. Anonymous — used internally so services can opt into public access (see allowAnonymous).

Authorization: RBAC & multitenancy

Roles are documents { name, permissions } where each permission is a string: an exact "<path>:<method>" (e.g. "widgets:create"), a path wildcard "widgets:*", or the global "*".

Two layers of authority:

  1. Global admin — a user whose populated role array contains a role named Admin (case-insensitive). Global admins bypass tenant scoping. Detected via isGlobalAdmin(user).
  2. Organization role — within their activeOrganization, a user's permissions come from the role recorded in that org's members[] entry.

teamAccessControl (applied by generateDefaultHooks) enforces, per request: the user is authenticated, is a member of the active organization, holds "<path>:<method>" for the action, and — for reads/writes — the query is constrained to organizationId === activeOrganization. On create it stamps the resource with the active organization. This is what makes every service multitenant without per-service code.

Helpers (@frozencrow/api-core), for use in custom methods:

import { isGlobalAdmin, assertOrgMembership, assertOrgPermission } from '@frozencrow/api-core'

await assertOrgMembership(app, user, organizationId)                  // throws if not a member
await assertOrgPermission(app, user, organizationId, 'widgets:patch') // throws if lacking permission

Seeded roles (seed: true): Admin (['*']) and Member (['users:get','users:patch','organizations:get','organizations:find']). Override with seed: { roles: [...] }.

Adding your own services

A resource is three files. Below is the complete pattern for a multitenant widgets service (also shipped in the scaffolder template).

widgets.schema.ts — TypeBox schemas + validators/resolvers, using the core's shared AJV validators:

import { resolve } from '@feathersjs/schema'
import { Type, getValidator, querySyntax, ObjectIdSchema } from '@feathersjs/typebox'
import type { Static } from '@feathersjs/typebox'
import { dataValidator, queryValidator, type HookContext } from '@frozencrow/api-core'

export const widgetSchema = Type.Object({
  _id: ObjectIdSchema(),
  name: Type.String(),
  organizationId: ObjectIdSchema(),      // scopes the row to a tenant
  createdAt: Type.Number(),
  updatedAt: Type.Number()
}, { $id: 'Widget', additionalProperties: false })
export type Widget = Static<typeof widgetSchema>

// Client sends `name`; resolvers/teamAccessControl set the rest — but they must
// be permitted here, because resolveData runs *before* validateData.
export const widgetDataSchema = Type.Intersect([
  Type.Pick(widgetSchema, ['name']),
  Type.Object({
    organizationId: Type.Optional(ObjectIdSchema()),
    createdAt: Type.Optional(Type.Number()),
    updatedAt: Type.Optional(Type.Number())
  })
], { $id: 'WidgetData' })
export type WidgetData = Static<typeof widgetDataSchema>

export const widgetDataValidator = getValidator(widgetDataSchema, dataValidator)
export const widgetDataResolver = resolve<Widget, HookContext>({
  organizationId: async (v, _d, ctx) => v || (ctx.params as any).user?.activeOrganization,
  createdAt: async () => Date.now(),
  updatedAt: async () => Date.now()
})
// ...patch + query schemas/validators/resolvers follow the same shape.

widgets.class.ts — a MongoDB-backed service:

import { MongoDBService } from '@feathersjs/mongodb'
import type { MongoDBAdapterOptions } from '@feathersjs/mongodb'
import type { Application } from '@frozencrow/api-core'
import type { Widget, WidgetData, WidgetPatch, WidgetQuery } from './widgets.schema'

export class WidgetService extends MongoDBService<Widget, WidgetData, any, WidgetPatch> {}

export const getOptions = (app: Application): MongoDBAdapterOptions => ({
  paginate: app.get('paginate'),
  Model: (app.get('mongodbClient') as Promise<any>).then((db) => db.collection('widgets'))
})

widgets.ts — register and wire hooks in one generateDefaultHooks call:

import { generateDefaultHooks } from '@frozencrow/api-core'
import type { Application } from '@frozencrow/api-core'
import { WidgetService, getOptions } from './widgets.class'
import * as schema from './widgets.schema'

export const widgetPath = 'widgets'

// Full typing for app.use('widgets') / app.service('widgets'):
declare module '@frozencrow/api-core/lib/declarations' {
  interface ServiceTypes { widgets: WidgetService }
}

export const widgets = (app: Application) => {
  app.use(widgetPath, new WidgetService(getOptions(app)), {
    methods: ['find', 'get', 'create', 'patch', 'remove'],
    events: []
  })
  app.service(widgetPath).hooks(generateDefaultHooks({
    schema: {
      dataValidator: schema.widgetDataValidator,
      patchValidator: schema.widgetPatchValidator,
      queryValidator: schema.widgetQueryValidator,
      dataResolver: schema.widgetDataResolver,
      patchResolver: schema.widgetPatchResolver,
      queryResolver: schema.widgetQueryResolver,
      resultResolver: schema.widgetResolver,
      externalResolver: schema.widgetExternalResolver
    }
  }))
}

Then app.configure(widgets) after createApp. The service is now JWT-protected, tenant-scoped, validated, and requires widgets:<method> permission — no extra code.

Extending built-in services

Need to add a field to users (or another core service)? The core schemas are strict (additionalProperties: false), so extra fields would be rejected. The extend option adds them properly — rebuilding the service's validators to accept the new fields while still rejecting truly-unknown ones.

import { Type } from '@feathersjs/typebox'

createConfiguredApp({
  extend: {
    users: {
      // Stored + returned + (below) queryable. Client-writable — guard
      // server-only fields with a hook if needed.
      properties: {
        phone: Type.Optional(Type.String()),
        stripeCustomerId: Type.Optional(Type.String())
      },
      // Make a field filterable in queries.
      queryProperties: { phone: Type.String() },
      // Optional resolvers, run in the correct slot alongside the core ones.
      resolvers: {
        data: { source: async (v) => v ?? 'signup' },        // server-set on create
        result: { displayName: async (_v, u) => `${u.firstName} ${u.lastName}` } // computed on read
      },
      // Optional extra Feathers hooks (merged onto the service's hooks).
      hooks: { after: { create: [syncToCrm] } }
    }
  }
})

Keys are service paths: users, organizations, roles, invites, verifications.

Typing the added fields

The runtime extend and the compile-time types are separate channels (module augmentation is static), so declare the fields once via the augmentable extension interface. Augment from @frozencrow/api-core/lib/declarations — the same entry the generated services use, and it resolves under every moduleResolution mode:

declare module '@frozencrow/api-core/lib/declarations' {
  interface UserExtensions {
    phone?: string
    stripeCustomerId?: string
  }
}

Now the fields are typed on the User entity and on context.params.user everywhere. There is a matching interface per service: UserExtensions, OrganizationsExtensions, RolesExtensions, InvitesExtensions, VerificationsExtensions.

Plain stored fields don't need a resolver — Feathers returns them as-is. additionalProperties: false still holds, so anything not declared in properties is rejected on write.

CLI

Installing @frozencrow/api-core also gives you the frozencrow command, so you don't have to write the three service files by hand:

# Scaffold a multitenant service with typed fields, and register it in src/app.ts
npx frozencrow generate service invoices --fields "amount:number,paid?:boolean" --wire

# Other generators / helpers
npx frozencrow generate hook audit-log
npx frozencrow secret --write     # write a strong secret into config/local.json
npx frozencrow list services

| Command | Description | | --- | --- | | generate service <name> (alias g s) | Creates <name>.schema.ts, <name>.class.ts, and the registration file. | | generate hook <name> (alias g h) | Creates a hook stub under src/hooks. | | secret | Prints a strong secret; --write sets authentication.secret in config/local.json. | | list services (alias ls) | Lists the services in the current project. |

generate service options

| Flag | Default | Notes | | --- | --- | --- | | --fields "a:string,b:number" | name:string | Types: string, number, boolean, integer; suffix ? marks a field optional | | --access team\|user\|public | team | team = multitenant, user = owner-scoped (restrictToUser), public = anonymous read/write | | --wire | off | Also add the import + app.configure(...) to src/app.ts | | --dir <path> | src/services | Output directory | | --force | off | Overwrite existing files |

Projects created with npm create @frozencrow/api also expose npm run generate and npm run secret shortcuts.

generateDefaultHooks / generateHooks reference

generateDefaultHooks(options) is generateHooks with team-based access control as the default mode. Both accept:

| Option | Default | Description | | --- | --- | --- | | schema | — | { dataValidator, patchValidator, queryValidator, dataResolver, patchResolver, queryResolver, resultResolver, externalResolver } — any subset | | methods | all 6 | service methods to wire | | requireAuth | true | adds authenticate('jwt', 'anonymous') | | accessControl.mode | teamAccessControl (default hooks) | teamAccessControl, 'restrictToUser', 'forbidden', 'ignore', or a custom (context) => … | | accessControl.methods | all 6 | which methods access control applies to | | accessControl.restrictToUserFrom / restrictToUserAs | params.user._id / params.query.userId | for restrictToUser mode | | allowAnonymous | false | permit unauthenticated access (public read/create) | | discardVirtuals | ['create','update','patch'] | strip virtual props before persistence | | logErrors | true | wrap methods in error logging | | overrides | — | replace a hook slot entirely, e.g. { before: { all: [...] } } | | extensions | — | append to a hook slot, e.g. { before: { create: [myHook] } } |

Example — a public, non-tenant service:

generateDefaultHooks({
  schema,
  requireAuth: false,
  allowAnonymous: true,
  accessControl: { mode: 'ignore' }
})

Organization membership management

The organizations service exposes custom methods (all require membership + organizations:patch, or global admin):

await app.service('organizations').invite(
  { organizationId, roleId, email, inviteType: 'email' }, { user })
await app.service('organizations').updateMemberRole({ organizationId, userId, roleId }, { user })
await app.service('organizations').removeMember({ organizationId, userId }, { user })
await app.service('organizations').getMembers({ organizationId }, { user }) // members-only

Impersonation

users.impersonate mints a JWT for another user/organization. It is restricted: global admins can impersonate anyone into any org ("Login As"); everyone else may only refresh their own session, and only into an organization they belong to.

const { accessToken } = await app.service('users').impersonate(
  { userId, organizationId }, { user: callingUser })

Invites & verifications

Invites create a verification token and (for email invites) send an email. Accepting is a patch to status: 'accepted'; the core verifies the caller (an email invite requires a matching email, link invites require any authenticated user, expired/consumed invites are rejected) and then adds the user to the organization with the invited role.

Verifications power self-service flows. Request one with create (magic-link or password-reset are allowed externally; invite is server-only). Reset a password by patching with { token, password } (no id). Look tokens up server-side with verificationsService.verifyToken(app, token, type). External find/get/remove are blocked and tokens never appear in responses.

Email & templates

Configure mail, then send:

import { sendTemplatedEmail, sendTextEmail } from '@frozencrow/api-core'

await sendTemplatedEmail(
  '[email protected]',
  'welcome',                                  // templates/welcome.{html,text}.dot
  { name: 'Ada' },
  app,
  { subject: 'Welcome!' }
)
await sendTextEmail('[email protected]', 'Hi', 'Plain body', app)

Bundled templates: welcome, invitation, notification, password-reset, wrapped by layout.{html,text}.dot. Common data (appName, clientUrl, logoUrl) is injected automatically. Override the whole directory with templatesDir.

Human-readable serial IDs

Generate per-organization, monotonic, human-readable IDs (backed by an atomic counter):

import { generateShortId } from '@frozencrow/api-core'

const id = await generateShortId(app, organizationId, 'work-order', 'WO')
// => "WO-2607-0001"  (prefix-YYMM-sequence)

Realtime events

Events are scoped per tenant, not broadcast to everyone. On login a connection joins authenticated, userId/<id>, and org/<activeOrganization> channels; each service event is published only to the organization it belongs to (falling back to the affected user for user events).

Extending channels

The channels option (on createApp / createConfiguredApp / configureCore) takes three forms:

Extend the defaults — add joins and publishing rules on top of the tenant-scoped behavior. A custom publish that returns undefined falls through to the default publisher, so tenant isolation stays intact:

const app = createConfiguredApp({
  channels: {
    onConnection: (connection, app) => app.channel('lobby').join(connection),
    onLogin: (authResult, connection, app) => {
      app.channel(`team/${authResult.user.teamId}`).join(connection)
    },
    publish: (data, context) => {
      // Broadcast announcements to everyone logged in…
      if (context.path === 'announcements') return context.app.channel('authenticated')
      // …everything else falls through to the tenant-scoped default.
    }
  }
})

Replace it — pass a function to own the whole setup:

createConfiguredApp({ channels: (app) => { /* your app.on/app.publish */ } })

Disable itchannels: false, then configure your own (the building blocks configureChannels, defaultPublisher, and buildPublisher are exported).

Independently of all of this, Feathers per-service publishers override the global publisher for that service:

app.service('messages').publish('created', (message) =>
  app.channel(`org/${message.organizationId}`)
)

Security model & production checklist

  • [ ] Set a strong AUTH_SECRET — the app refuses to boot in production (NODE_ENV=production) with a missing/placeholder/short (< 32 char) secret.
  • [ ] Set origins to your real browser origins (CORS is restricted to them).
  • [ ] Configure mail if you use password reset / magic link / invites.
  • [ ] Keep the Admin role name intact — it is the global-admin signal.

Guarantees baked in: privileged user fields (role, emailVerified, oauthVerified, Google identity) can't be self-assigned via the public API; impersonate is admin-only; verification tokens are never exposed and their service blocks external reads; realtime is tenant-scoped.

Exports

App: createConfiguredApp, createApp, configureCore, resolveConfiguration, configurationValidator, CoreOptions, ConfiguredAppOptions. Channels: configureChannels, defaultPublisher, buildPublisher, ChannelsOptions. Services: coreServices, services, ALL_CORE_SERVICES, and namespaced usersService, organizationsService, rolesService, invitesService, verificationsService, serialIdsService (for schema composition). Extending services: resolveServiceSchema, withExtensionHooks, ServiceExtension, CoreExtend. Hooks & access: generateHooks, generateDefaultHooks, teamAccessControl, isGlobalAdmin, assertOrgMembership, assertOrgPermission, populateUserRoles, preventRoleChange, filterOrganizationsByMembership, filterUsersByOrganization, setupOrganization, ensureUserHasOrganization. Strategies: CustomJWTStrategy, ApiKeyStrategy, AnonymousStrategy, GoogleStrategy, GoogleOneTapStrategy. Utilities: generateShortId, sendTemplatedEmail, mail helpers, logger, dataValidator, queryValidator. Types: Application, HookContext. A separate client entry is available at @frozencrow/api-core/client.

License

MIT