AppKit is a Node.js backend framework designed for agentic code workflows. Twelve typed modules, one canonical pattern, and machine-readable specs (llms.txt + AGENTS.md) in every package — so AI coding agents generate correct code, and developers review work they actually understand.
npm install @bloomneo/appkit
This page is written for humans. Agents should fetch /appkit/llms.txt and /appkit/AGENTS.md as the authoritative specs — they list every method signature, the canonical pattern, and what NOT to do. Same files ship inside the published npm package under docs/ and are kept in sync on every release.
The minimum to have every module available in a single file. All twelve follow the same xxxClass.get() pattern — no constructors, no factories.
import {
authClass, databaseClass, securityClass, errorClass,
cacheClass, storageClass, queueClass, emailClass,
eventClass, loggerClass, configClass, utilClass,
} from '@bloomneo/appkit';
const auth = authClass.get();
const database = await databaseClass.get(); // async — opens Prisma connection
const security = securityClass.get();
const error = errorClass.get();
const cache = cacheClass.get('users'); // optional namespace
const storage = storageClass.get();
const queue = queueClass.get();
const email = emailClass.get();
const events = eventClass.get();
const logger = loggerClass.get('api'); // optional component label
const config = configClass.get();
const util = utilClass.get();
AppKit is the Node.js backend toolkit teams reach for when they need to ship — not evaluate ten libraries.
@bloomneo/bloom CLI for web, Electron desktop or Capacitor mobile in one command.Every module follows xxxClass.get(). No constructors, no factories, no special cases. If you're writing new SomethingClass(), you're doing it wrong.
import {
authClass, databaseClass, errorClass, loggerClass,
cacheClass, storageClass, queueClass, emailClass,
eventClass, configClass, securityClass, utilClass,
} from '@bloomneo/appkit';
// Always at module scope — NEVER inside route handlers
const auth = authClass.get();
const database = await databaseClass.get(); // async — initializes Prisma connection
const error = errorClass.get();
const logger = loggerClass.get('api'); // optional component label
const cache = cacheClass.get('users'); // optional namespace for isolation
These are the only ways to misuse AppKit. Memorise them — or point your AI agent at AGENTS.md where they're spelled out line by line.
new AuthClass(), new DatabaseClass(), etc. Always authClass.get(). There are no constructors. Every module is a singleton.import { … } from '@bloomneo/appkit'. Subpath imports are for tree-shaking only.process.env.X directly in business code. Go through configClass.get('section.key') so missing secrets fail at boot, not at 3am.throw new Error(...) from an Express route. Use error.badRequest(), error.notFound(), etc. Otherwise you leak stack traces and return HTTP 500 for things that aren't 500.requireUserRoles() after requireApiToken(). API tokens don't have user roles. Chain requireUserRoles() after requireLoginToken() only.authClassJWT login, API tokens, roles, middleware, password hashingdatabaseClassPrisma client, multi-tenant filtering, per-org databasessecurityClassRate limiting, CSRF, AES-256-GCM encryption, sanitizationerrorClassSemantic HTTP errors, async route wrapper, error middlewarecacheClassMemory → Redis auto-scaling, getOrSet(), namespacesstorageClassLocal → S3/R2, presigned URLs, file listingqueueClassMemory → Redis background jobs, retries, delaysemailClassConsole → SMTP → Resend, templates, batch sendeventClassMemory → Redis pub/sub, wildcards, namespacesloggerClassStructured JSON logs, multi-transport (console/file/HTTP)configClassType-safe env access, boot-time validationutilClass12 zero-dep helpers: slugify, uuid, chunk, clamp, …Two token types: login tokens (humans) and API tokens (services / webhooks). Both verified with the same verifyToken().
const auth = authClass.get();
// Token generation
auth.generateLoginToken(payload, expiresIn?: string): string
// payload: { userId: number, role: string, level: string, permissions?: string[] }
// default expiresIn: '7d'
auth.generateApiToken(payload, expiresIn?: string): string
// payload: { keyId: string, role: string, level: string, permissions?: string[] }
// default expiresIn: '1y'
// Verification — handles BOTH token types. Throws on invalid token.
auth.verifyToken(token: string): JwtPayload
// Password hashing
auth.hashPassword(plain: string, rounds?: number): Promise<string>
auth.comparePassword(plain: string, hash: string): Promise<boolean> // never throws
// Express middleware factories
auth.requireLoginToken(options?): ExpressMiddleware // authenticate user
auth.requireApiToken(options?): ExpressMiddleware // authenticate service
auth.requireUserRoles(['admin.tenant']): ExpressMiddleware // role check (OR logic)
auth.requireUserPermissions(['manage:users']): ExpressMiddleware // permission check (AND logic)
// Request helpers
auth.getUser(req): JwtPayload | null // null-safe extract from req.user
auth.hasRole(userRole, requiredRole): boolean // inheritance-aware comparison
auth.hasPermission(user, permission): boolean
import { authClass, databaseClass, errorClass } from '@bloomneo/appkit';
const auth = authClass.get();
const database = await databaseClass.get();
const error = errorClass.get();
app.post('/auth/login', error.asyncRoute(async (req, res) => {
const { email, password } = req.body ?? {};
if (!email || !password) throw error.badRequest('Email and password required');
const user = await database.user.findUnique({ where: { email } });
if (!user) throw error.unauthorized('Invalid credentials');
const valid = await auth.comparePassword(password, user.password);
if (!valid) throw error.unauthorized('Invalid credentials');
const token = auth.generateLoginToken({
userId: user.id,
role: user.role,
level: user.level,
});
res.json({ token, user: { id: user.id, email: user.email } });
}));
app.delete(
'/admin/users/:id',
auth.requireLoginToken(), // 1. authenticate — sets req.user
auth.requireUserRoles(['admin.tenant']), // 2. authorize — chained AFTER authenticate
error.asyncRoute(async (req, res) => {
await database.user.delete({ where: { id: Number(req.params.id) } });
res.json({ deleted: true });
})
);
// IMPORTANT: requireUserRoles() always chains AFTER requireLoginToken().
// requireApiToken() is for service routes — never chain requireUserRoles() after it.
| Level | Role.Level | Typical use |
|---|---|---|
| 1 | user.basic | Free / read-only tier |
| 2 | user.pro | Paid tier |
| 3 | user.max | Premium tier |
| 4 | moderator.review | Can flag content |
| 5 | moderator.approve | Can publish content |
| 6 | moderator.manage | Can manage moderators |
| 7 | admin.tenant | Tenant administrator |
| 8 | admin.org | Organisation admin |
| 9 | admin.system | Super admin |
requireUserRoles(['admin.tenant']) accepts levels 7, 8, and 9 via inheritance. Add custom roles with BLOOM_AUTH_ROLES=role.level:N,....
const database = await databaseClass.get();
// Standard Prisma queries — auto-filtered by tenant_id when BLOOM_DB_TENANT=auto
database.user.findMany({ where: { active: true } })
database.user.create({ data: { email, name } }) // auto-stamps tenant_id
// Cross-tenant queries (admin only)
const dbAll = await databaseClass.getTenants();
dbAll.user.groupBy({ by: ['tenant_id'], _count: true })
// Per-org databases (multi-org SaaS)
const acmeDb = await databaseClass.org('acme').get(); // reads ORG_ACME env var
const security = securityClass.get();
// Rate limiting middleware
app.use(security.requests(100, 900_000)); // 100 req per 15 min per IP
// CSRF — one method that does both injection AND validation on HTML form routes
app.use(security.forms());
// AES-256-GCM encryption
const ciphertext = security.encrypt('secret data');
const plaintext = security.decrypt(ciphertext);
// Input sanitization (strips XSS — NOT email/URL validation; use zod for those)
const clean = security.input(userInput); // strip XSS + control chars
const safeHtml = security.html(userHtml); // strip disallowed HTML tags
const escaped = security.escape(userText); // & < > etc.
async routes without try/catch boilerplateconst error = errorClass.get();
// Throw semantic errors — each sets the correct HTTP status automatically
throw error.badRequest('validation message') // 400
throw error.unauthorized('auth failed') // 401
throw error.forbidden('access denied') // 403
throw error.notFound('resource missing') // 404
throw error.conflict('email already exists') // 409
throw error.tooMany('slow down') // 429
throw error.internal('unexpected error') // 500
// Wrap async routes — catches thrown errors and forwards to middleware
app.get('/items/:id', error.asyncRoute(async (req, res) => {
const item = await database.item.findUnique({ where: { id: +req.params.id } });
if (!item) throw error.notFound('Item not found');
res.json(item);
}));
// Mount LAST — handles all semantic errors globally
app.use(error.handleErrors());
const cache = cacheClass.get('users'); // namespace for isolation
// The recommended pattern — cache-or-fetch in one call
const users = await cache.getOrSet(
'all-active',
() => database.user.findMany({ where: { active: true } }),
300 // TTL in seconds
);
// Low-level operations
await cache.set('key', value, ttlSeconds?);
const val = await cache.get<User>('key'); // returns null if missing
await cache.delete('key'); // NOTE: delete(), not del()
// Check existence — there is no cache.has(). Use null check:
const v = await cache.get('key');
if (v !== null) { /* key exists */ }
// Invalidate after mutation
await cache.delete('all-active');
Set REDIS_URL to upgrade from in-memory to Redis automatically.
const storage = storageClass.get();
// Upload a file
await storage.put('avatars/user-123.jpg', buffer, { contentType: 'image/jpeg' });
// Download, check, delete, list
const buf = await storage.get('avatars/user-123.jpg');
const exists = await storage.exists('avatars/user-123.jpg'); // NOTE: exists(), not has()
await storage.delete('avatars/user-123.jpg'); // NOTE: delete(), not del()
const files = await storage.list('avatars/', 100);
// URLs
const publicUrl = storage.url('avatars/user-123.jpg');
const signedUrl = await storage.signedUrl('private/doc.pdf', 3600); // 1hr expiry
await storage.copy('src/file.jpg', 'dst/file.jpg');
Set AWS_S3_BUCKET (or R2_BUCKET) to upgrade from local disk to cloud storage.
const queue = queueClass.get();
// Enqueue a job
await queue.add('send-email', { to: 'user@example.com', template: 'welcome' }, {
delay: 0, // ms to wait before running
attempts: 3, // max retries (NOTE: "attempts" not "retries")
priority: 1, // lower = higher priority
});
// Register a worker — throw to trigger retry
queue.process('send-email', async (data) => {
await email.sendTemplate('welcome', { to: data.to });
});
// Delay — schedule once N milliseconds from now (NOT a cron expression)
await queue.schedule('cleanup-tokens', {}, 8 * 60 * 60 * 1000); // 8 hours
// Management
await queue.pause('send-email');
await queue.resume('send-email');
const stats = await queue.getStats('send-email');
await queue.retry(jobId);
await queue.remove(jobId);
In-memory by default. Set REDIS_URL for persistent, distributed queues.
const email = emailClass.get();
// Plain or HTML email
await email.send({
to: 'user@example.com',
subject: 'Welcome to the platform',
text: 'Plain-text fallback',
html: '<p>HTML body</p>', // send both for best deliverability
from: 'noreply@yourapp.com',
replyTo: 'support@yourapp.com',
});
// Convenience shortcuts
await email.sendText(to, subject, text);
await email.sendHtml(to, subject, html, text?);
// Template email — all data in one object
await email.sendTemplate('password-reset', {
to: user.email,
resetLink: 'https://app.com/reset?token=abc',
expiresIn: '1 hour',
});
// Batch
await email.sendBatch(emailsArray, batchSize?);
Default is console in dev. Set RESEND_API_KEY (or SMTP_HOST + SMTP_USER + SMTP_PASS) to switch transports automatically.
const events = eventClass.get();
const userEvents = eventClass.get('users'); // namespace isolation
// Subscribe
events.on('user.created', async (data) => {
await email.sendTemplate('welcome', { to: data.email });
});
events.on('user.*', (data) => { // wildcard pattern
logger.info('User event', data);
});
// Emit
await events.emit('user.created', { id: 123, email: 'new@example.com' });
// Unsubscribe
events.off('user.created', handler);
Set REDIS_URL to distribute events across multiple processes or servers via Redis pub/sub.
const logger = loggerClass.get('api'); // component-tagged
logger.debug('Processing request', { path: req.path });
logger.info('User created', { userId: newUser.id });
logger.warn('Rate limit approaching', { ip: req.ip, count: 95 });
logger.error('Database query failed', { error: err.message });
logger.fatal('Service unresponsive', { service: 'redis' });
Console transport by default. Set BLOOM_LOGGER_FILE_PATH for file transport or BLOOM_LOGGER_HTTP_URL for HTTP shipping.
NODE_ENV-aware dev / staging / prod / test branchesprocess.env out of business logicconst config = configClass.get();
// Read values
config.get('auth.secret', defaultValue?) // always returns string | undefined
config.getRequired('auth.secret') // throws if missing
config.has('auth.secret'): boolean
config.getMany(['KEY_A', 'KEY_B']): Record<string, string | undefined>
// NOTE: getNumber() and getBoolean() don't exist. Parse manually:
const port = Number(config.get('api.port') ?? 3000);
const debug = config.get('app.debug') === 'true';
// Static helpers — on configClass directly (not on the instance)
configClass.isDevelopment(): boolean
configClass.isProduction(): boolean
configClass.isTest(): boolean
configClass.validateRequired(['BLOOM_AUTH_SECRET', 'DATABASE_URL']) // throws if any missing
const util = utilClass.get();
util.get(obj, 'a.b.c', defaultValue) // safe deep property read (no util.set())
util.pick(obj, ['a', 'b']) // keep listed keys (no util.omit())
util.isEmpty(value): boolean // true for null, undefined, '', [], {}
util.chunk(array, 5) // split into batches of 5
util.unique(array) // deduplicate
util.clamp(150, 0, 100) // → 100
util.debounce(fn, 300) // (no util.throttle())
util.sleep(1000) // Promise-based delay
util.uuid() // v4 UUID
util.slugify('Hello World') // → 'hello-world'
util.formatBytes(1_572_864) // → '1.5 MB'
util.truncate(longText, 80) // truncate with ellipsis
| Module | Default | Set this env var | Upgrades to |
|---|---|---|---|
| Cache | in-memory | REDIS_URL | Redis |
| Queue | in-memory | REDIS_URL | Redis |
| Events | in-memory | REDIS_URL | Redis pub/sub |
| Storage | local disk | AWS_S3_BUCKET or R2_BUCKET | S3 / Cloudflare R2 |
| console log | SMTP_HOST or RESEND_API_KEY | SMTP / Resend | |
| Logger | console | BLOOM_LOGGER_FILE_PATH | + file transport |
| Variable | Purpose | Required? |
|---|---|---|
BLOOM_AUTH_SECRET | JWT signing key (min 32 chars) | Yes |
DATABASE_URL | Prisma connection string | Yes |
BLOOM_SECURITY_CSRF_SECRET | CSRF token signing | Recommended |
BLOOM_SECURITY_ENCRYPTION_KEY | AES-256-GCM (64 hex chars) | Recommended |
REDIS_URL | Distributed cache + queue + events | Optional |
AWS_S3_BUCKET | Cloud storage bucket | Optional |
RESEND_API_KEY | Production transactional email | Optional |
SMTP_HOST / SMTP_USER / SMTP_PASS | SMTP email | Optional |
BLOOM_DB_TENANT=auto | Enable multi-tenant filtering | Optional |
ORG_<NAME> | Per-org database URL | Optional |
BLOOM_LOGGER_FILE_PATH | Log to file | Optional |
BLOOM_LOGGER_HTTP_URL | Log to HTTP endpoint | Optional |
BLOOM_AUTH_ROLES | Custom role.level definitions | Optional |
// Preferred — flat import when using multiple modules
import { authClass, databaseClass, errorClass, cacheClass } from '@bloomneo/appkit';
// Alternative — subpath for tree-shaking when only one module is needed
import { authClass } from '@bloomneo/appkit/auth';
import { databaseClass } from '@bloomneo/appkit/database';
import { securityClass } from '@bloomneo/appkit/security';
import { errorClass } from '@bloomneo/appkit/error';
import { cacheClass } from '@bloomneo/appkit/cache';
import { storageClass } from '@bloomneo/appkit/storage';
import { queueClass } from '@bloomneo/appkit/queue';
import { emailClass } from '@bloomneo/appkit/email';
import { eventClass } from '@bloomneo/appkit/event';
import { loggerClass } from '@bloomneo/appkit/logger';
import { configClass } from '@bloomneo/appkit/config';
import { utilClass } from '@bloomneo/appkit/util';
~80% of AppKit endpoints follow this shape. Generate this as the default unless told otherwise.
import { authClass, databaseClass, errorClass, loggerClass } from '@bloomneo/appkit';
const auth = authClass.get();
const database = await databaseClass.get();
const error = errorClass.get();
const logger = loggerClass.get('users');
app.post(
'/api/users',
auth.requireLoginToken(), // 1. authenticate
auth.requireUserRoles(['admin.tenant']), // 2. authorize (always chained after authenticate)
error.asyncRoute(async (req, res) => {
if (!req.body?.email) throw error.badRequest('Email required');
const user = await database.user.create({ data: req.body });
logger.info('User created', { userId: user.id });
res.json({ user });
})
);
// Mount once, last in the stack
app.use(error.handleErrors());
xxxClass.get()) and includes llms.txt and AGENTS.md machine-readable specs in every release so agents generate correct code the first time.
auth.requireLoginToken(), error.asyncRoute(), security.requests()) are compatible with both Express and Fastify. Non-HTTP modules (cache, queue, storage, email, events, logger, config, util, database) are framework-agnostic and work in any Node.js process — HTTP server, worker, CLI, or cron job.
https://dev.bloomneo.com/appkit/llms.txt and /appkit/AGENTS.md — these are authoritative, machine-readable specs. (2) Scaffold with @bloomneo/bloom, which copies the latest llms.txt and AGENTS.md into your project's docs/ folder on install. Most agents pick them up automatically from that location.
REDIS_URL, AWS_S3_BUCKET, RESEND_API_KEY, BLOOM_LOGGER_FILE_PATH — and the same module transparently switches to the production backend. No refactor, no new library, no code change.
BLOOM_DB_TENANT=auto and every Prisma query is automatically scoped to the current tenant via row-level filtering. For multi-org deployments where each customer has its own database, use databaseClass.org('acme').get() backed by per-org connection strings (ORG_ACME=postgresql://…).
schema.prisma stays yours; AppKit adds multi-tenant filtering and per-org connection handling on top.
BLOOM_AUTH_SECRET, DATABASE_URL, BLOOM_SECURITY_CSRF_SECRET) are validated on boot so missing secrets crash early, not at 3am.
llms.txtFull API reference — every method, every signature. Auto-regenerated on each build. View →
AGENTS.mdRules for AI agents: always-do, never-do, canonical patterns. Prevents hallucinated method names. View →
docs/Bloom CLI postinstall copies the latest llms.txt and AGENTS.md into your project's docs/ on every npm install.