Shared platform foundations for authentication, profiles, and notifications across social, travel, workout, and basa.
Documentation site: https://charles2ke.github.io/platform-shared/ (published from docs/).
- Runtime: Node.js >= 22, ES modules, zero runtime dependencies.
- Quality gates: CI test matrix (Node 22 and 24), syntax checks,
npm audit, CodeQL default setup, Dependabot for npm and GitHub Actions. - Governance:
SECURITY.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md, issue/PR templates, andCODEOWNERS.
This package is intentionally framework-light and dependency-free. Core business logic lives in reusable modules under src/, while app-specific HTTP, queue, or serverless integration should wrap these modules in each downstream repository.
src/
auth/ JWT issuing/verification, refresh pattern, route guards, RBAC helpers
profile/ Canonical profile model validation plus CRUD service and memory adapter
notifications/ Unified send/schedule API, template rendering, channel adapters
shared/ Environment config, structured errors, logging hooks
adapters/ Framework/provider adapters: HTTP middleware, HTTP channel, worker loop
examples/ Integration stubs for social, travel, workout, and basa
- Issues and verifies HMAC SHA-256 JWT access and refresh tokens.
- Covers the full token lifecycle:
issueTokenPair(),refreshAccessToken(),rotateTokenPair()for refresh-token rotation, and revocation through aTokenRevocationStore. rotateTokenPair()detects refresh-token replay: presenting an already-rotated refresh token revokes every session for the subject (unlessrevokeSubjectOnReuse: false), invokes the optionalonReuseDetected({ subject, payload })hook, and throwsAUTH_REFRESH_TOKEN_REUSED.decodeToken()inspects a token without verifying it (logging/debugging only), anddescribeToken()summarizes a verified payload (issuedAt,expiresAt,expiresInSeconds,expired) for session/introspection endpoints.InMemoryTokenRevocationStoresupports single-token revocation (revokeToken()), "log out everywhere" (revokeSubject()), andprune()for expired entries. Implementations must be synchronous so guards stay synchronous.- Provides
createAuthGuard()andprotect()helpers for framework-specific route wrappers. Guards acceptrevocationStoreandroleRegistryso revocation and RBAC are enforced on every request. issueTokenPair()stamps a shared session id (sid) on the access and refresh token and returns it assessionId, so one login can be revoked as a unit.revokeSession(payload, { revocationStore })ends that session (logout) and falls back to single-token revocation for tokens issued without asid. EachrotateTokenPair()call starts a new session unlesssessionIdis passed; replaying a rotated refresh token also revokes the replayed session.- Includes RBAC scaffolding with role and permission checks, a
createRoleRegistry()role-to-permission map with inheritance,resolvePrincipal()for expanding token roles into permissions, andauthorize()for enforcement outside route guards. - Role inheritance also applies to role checks:
registry.rolesFor()expands inherited roles andresolvePrincipal()stores them onprincipal.effectiveRoles(tokenrolesstay untouched), so acoachthat inheritsathletesatisfies{ roles: ['athlete'] }. createAccessPolicy({ 'profile.update': { permissions: ['profile:write'] } })maps action names to requirements so RBAC is enforced inside services, jobs, and queue consumers, not only on HTTP routes.ProfileServiceandNotificationServiceaccept the resultingpolicy(or a plain requirement map) and aroleRegistry; callers then pass{ principal }per call. Actions without requirements stay open; a missing principal fails withAUTH_PRINCIPAL_REQUIREDunlessrequirePrincipal: false.- Exposes an
AccountStoreinterface plus anInMemoryAccountStoredefault;MongoAccountStore(instorage) is a persistent adapter with case-insensitivefindByEmail()backed by a unique index (duplicate emails fail withAUTH_ACCOUNT_EMAIL_CONFLICT, 409). createLoginThrottle({ maxAttempts, windowMs, lockoutMs, maxEntries })slows password guessing:recordFailure(key)counts failures per caller-chosen key (account, IP, or both),assertAllowed(key)throwsAUTH_LOGIN_LOCKED(429,details.retryAfterSeconds) while a key is locked out, andrecordSuccess(key)clears it. It is synchronous, in-process, and memory-bounded; scaled-out deployments that need a shared view should provide a shared implementation of the same methods.
- Uses a canonical profile shape:
id,displayName,contact,preferences,timezone,locale,avatarUrl,status, andmetadata. - Normalizes display names, email addresses, locale/timezone defaults, and status.
- Provides validation helpers and
ProfileServiceover the replaceableProfileStoreinterface. - Ships
InMemoryProfileStorefor tests, prototypes, and local development. softDelete(id)marks a profiledeletedwith adeletedAttimestamp (enforcesprofile.delete);restore(id, { status })brings it back (defaultactive; enforcesprofile.restore, orprofile.deletewhen noprofile.restorerequirement is configured).delete(id)still removes permanently.update()applies the same rules when astatuschange moves a profile into or out ofdeleted(extra policy check anddeletedAtbookkeeping), soprofile.updatealone cannot soft-delete or restore.search({ query, status, includeDeleted, limit, cursor })matchesquerycase-insensitively against display name and email, hides soft-deleted profiles by default, and returns{ items, nextCursor }ordered byid(enforcesprofile.search, orprofile.listwhen not configured). Stores may implementsearch()natively (InMemoryProfileStore,MongoProfileStore, andCachedProfileStoredo); otherwise the service filtersstore.list(), which is only complete iflist()returns every profile.
- Provides one API for sending or scheduling notifications.
- Supports email, SMS, and push channel abstraction.
strategy: 'fallback'trieschannelsin order and stops at the first channel that does not fail (for example push, then SMS, then email); the result issentif any channel delivered. The defaultstrategy: 'all'delivers on every channel.- Quiet hours: when a notification carries
quietHours: { start: '22:00', end: '07:00', timezone },send()queues it for the end of the window (resultstatus: 'pending',deferred: true) andschedule()shifts times that fall inside the window.bypassQuietHours: truesends urgent messages immediately.quietHoursFromProfile(profile)readsprofile.preferences.quietHoursand defaults the timezone toprofile.timezone. Quiet hours are evaluated atsend()/schedule()time; retries queued bydispatchScheduled()are not re-checked. - Renders
{{variable}}template placeholders from provided variables. - Returns delivery status objects with
sent,failed,partial, orpendingstates. - Includes an in-memory scheduling workflow via
schedule()anddispatchScheduled()for queue handoff patterns. - Ships a
NotificationSchedulerinterface plusInMemoryNotificationScheduler(enqueue/dequeueDue/requeue/countPending/list) as a reference queue adapter. - Retries use configurable exponential backoff:
retryDelayMs * retryBackoffFactor ** (attempts - 1), capped bymaxRetryDelayMs.retryDelayFor(attempts)exposes the computed delay. - Exhausted retries (or schedulers without
requeue()) call the optionalonDeadLetter({ notification, attempts, reason })hook;dispatchScheduled()reportsretriedanddeadLetteredcounts and per-resultattempts,retryScheduledFor, anddeadLettered. dispatchScheduled()returnspendingpluspendingKnown; when an injected scheduler does not exposecountPending(),pendingis0andpendingKnownisfalse.- In-memory retries honor
maxScheduleAttemptsandretryDelayMs; injected schedulers can support retries by implementingrequeue(). maxScheduleAttemptsbounds total delivery attempts (initial attempt included). Scheduler adapters should return wrapped due entries as{ notification, scheduledFor }(or{ notification, when }).listScheduled()returns pending entries andcancelScheduled(notificationId)drops queued deliveries (trip cancelled, workout completed early); injected schedulers must implementlist()andcancel()for these.- Partial failures are retried per channel: only the channels that failed are re-queued (
result.retryChannels), so delivered channels are never sent twice. SetretryPartialFailures: falseto keep the previous behavior. - A
deadLetterStore(seeDeadLetterStore/InMemoryDeadLetterQueue) persists{ notification, attempts, reason, channels, failedAt }records for exhausted retries so failures can be inspected and replayed withreplayDeadLetters(). - Enforces the shared access policy on
send(),schedule(),listScheduled(),cancelScheduled(), anddispatchScheduled()whenpolicyis supplied. - Defines a
ChannelAdapterinterface and shipsMockChannelAdapterfor default/local provider behavior.
createExpressAuthMiddleware(guard, { requirements })mounts a guard in Express/Connect apps and answers with the shared error envelope;withFetchAuth(handler, guard, requirements)does the same for fetch-style route handlers (Next.js, Hono, workers).toHttpErrorResponse(error)maps any error to{ status, body }for consistent API error responses.HttpChannelAdapteris a concrete channel adapter that POSTs rendered notifications to a provider endpoint, withheaders,transform, andtimeoutMsoptions.createNotificationWorker(service, { intervalMs })drains scheduled notifications from a cron trigger (runOnce()) or a long-running worker (start()/stop()), andreplayDeadLetters({ service, store })re-queues dead-lettered notifications after an outage.
createBackgroundWorker({ handler, intervalMs })is a generic background job runner: schedule it withstart()/stop()or trigger it on demand withrunOnce()from an HTTP route, CLI command, or external cron. Overlapping runs are skipped,timeoutMsbounds a run, failures are normalized toPlatformError(and forwarded toonErrorinstead of thrown when supplied), andgetStats()exposes run counters for metrics and health endpoints.
createRedisCache({ client, ttlSeconds, ttlByPrefix, metrics })is a fail-open two-tier cache (bounded in-process LRU in front of an injected Redis client) with single-flightgetOrLoad(). When ametricsregistry is supplied it recordscache_hits_total,cache_misses_total, andcache_errors_total.ttlByPrefix: { 'profile:': 600, 'profile:hot:': 30 }sets default TTLs per key prefix; the longest matching prefix wins, an explicitset(key, value, { ttl })still overrides, andttlFor(key)reports the effective TTL.CachedProfileStorewraps anyProfileStorewith read-through caching and passessearch()through uncached.
loadConfig()reads environment configuration.PlatformErrorgives structured error objects with code, status, message, and details.- Logger hooks accept any object with
debug,info,warn, anderrormethods.
Copy .env.example and set values in each app environment.
| Variable | Required | Purpose |
|---|---|---|
PLATFORM_JWT_SECRET |
Production yes | JWT HMAC secret; use at least 32 random characters. |
PLATFORM_JWT_ISSUER |
No | JWT issuer, defaults to platform-shared. |
PLATFORM_JWT_AUDIENCE |
No | App or API audience to verify. |
PLATFORM_JWT_ACCESS_TTL_SECONDS |
No | Access token TTL, defaults to 900 seconds. |
PLATFORM_JWT_REFRESH_TTL_SECONDS |
No | Refresh token TTL, defaults to 30 days. |
PLATFORM_DEFAULT_LOCALE |
No | Profile default locale, defaults to en-US. |
PLATFORM_DEFAULT_TIMEZONE |
No | Profile default timezone, defaults to UTC. |
PLATFORM_DEFAULT_FROM_EMAIL |
No | Default email sender for provider adapters. |
PLATFORM_DEFAULT_SMS_SENDER |
No | Default SMS sender for provider adapters. |
PLATFORM_DEFAULT_PUSH_SENDER |
No | Default push sender for provider adapters. |
import { issueTokenPair, createAuthGuard } from '@charles2ke/platform-shared/auth';
const tokens = issueTokenPair({
subject: 'user-123',
roles: ['member'],
permissions: ['profile:read'],
secret: process.env.PLATFORM_JWT_SECRET,
issuer: 'platform-shared',
audience: 'social'
});
const guard = createAuthGuard({
secret: process.env.PLATFORM_JWT_SECRET,
issuer: 'platform-shared',
audience: 'social'
});
const principal = guard(request, { permissions: ['profile:read'] });import {
createAuthGuard,
createRoleRegistry,
describeToken,
InMemoryTokenRevocationStore,
rotateTokenPair
} from '@charles2ke/platform-shared/auth';
const revocationStore = new InMemoryTokenRevocationStore();
const roleRegistry = createRoleRegistry({
member: ['profile:read'],
moderator: { permissions: ['post:delete'], inherits: ['member'] }
});
const guard = createAuthGuard({ secret, issuer: 'platform-shared', audience: 'social', revocationStore, roleRegistry });
// Refresh endpoint: rotate the pair, revoke the presented refresh token, and
// treat a replayed refresh token as a compromise (all sessions are revoked).
const rotated = rotateTokenPair(refreshToken, {
secret,
issuer: 'platform-shared',
audience: 'social',
revocationStore,
onReuseDetected: ({ subject }) => securityLog.warn('refresh token replay', { subject })
});
// Session/introspection endpoint.
const session = describeToken(principal.claims); // { expiresAt, expiresInSeconds, expired, ... }
// Inherited roles are available on principal.effectiveRoles.
guard(request, { roles: ['member'] });
// Logout endpoints.
revokeSession(principal.claims, { revocationStore }); // ends this login (access + refresh)
revocationStore.revokeToken(principal.claims); // single token
revocationStore.revokeSubject('user-123'); // everywhereimport { createAccessPolicy } from '@charles2ke/platform-shared/auth';
import { ProfileService } from '@charles2ke/platform-shared/profile';
const policy = createAccessPolicy({
'profile.get': { permissions: ['profile:read'] },
'profile.update': { permissions: ['profile:write'] },
'profile.delete': { roles: ['admin'] }
}, { roleRegistry });
const profiles = new ProfileService({ store, policy });
await profiles.update(id, { timezone: 'Africa/Nairobi' }, { principal });import { createExpressAuthMiddleware, createNotificationWorker, HttpChannelAdapter, replayDeadLetters, withFetchAuth } from '@charles2ke/platform-shared/adapters';
app.get('/feed', createExpressAuthMiddleware(guard, { requirements: { roles: ['member'] } }), handler);
export const GET = withFetchAuth(routeHandler, guard, { permissions: ['profile:read'] });
const notifications = new NotificationService({
adapters: { email: new HttpChannelAdapter({ channel: 'email', endpoint: process.env.EMAIL_WEBHOOK_URL, headers: { authorization: providerKey } }) },
scheduler,
deadLetterStore
});
const worker = createNotificationWorker(notifications, { intervalMs: 60_000 });
worker.start();
await replayDeadLetters({ service: notifications, store: deadLetterStore });import { ProfileService } from '@charles2ke/platform-shared/profile';
const profiles = new ProfileService({ defaults: { locale: 'en-US', timezone: 'UTC' } });
const profile = await profiles.create({
displayName: 'Charles',
contact: { email: '[email protected]' },
preferences: { units: 'metric' }
});
await profiles.softDelete(profile.id);
await profiles.restore(profile.id);
const page = await profiles.search({ query: 'charles', limit: 20 });
const next = await profiles.search({ query: 'charles', limit: 20, cursor: page.nextCursor });import { ProfileStore } from '@charles2ke/platform-shared/profile';
import { ChannelAdapter } from '@charles2ke/platform-shared/notifications';
class SqlProfileStore extends ProfileStore {
async create(profile) { /* app-owned persistence */ }
async get(id) { /* ... */ }
async update(id, profile) { /* ... */ }
async delete(id) { /* ... */ }
async list() { /* ... */ }
}
class EmailProviderAdapter extends ChannelAdapter {
async send(message) { /* call provider, return a delivery status object */ }
}Unimplemented interface methods throw structured PlatformErrors instead of failing silently.
import { NotificationService, quietHoursFromProfile } from '@charles2ke/platform-shared/notifications';
await notifications.send({
id: 'trip-reminder-42',
channels: ['push', 'sms', 'email'],
strategy: 'fallback',
quietHours: quietHoursFromProfile(profile), // e.g. 22:00-07:00 in the profile's timezone
body: 'Your flight boards in 3 hours'
});import { createLoginThrottle } from '@charles2ke/platform-shared/auth';
const throttle = createLoginThrottle({ maxAttempts: 5, windowMs: 15 * 60_000, lockoutMs: 15 * 60_000 });
function login(email, password, ip) {
const keys = [`account:${email.toLowerCase()}`, `ip:${ip}`];
keys.forEach((key) => throttle.assertAllowed(key)); // throws AUTH_LOGIN_LOCKED (429)
if (!checkPassword(email, password)) {
keys.forEach((key) => throttle.recordFailure(key));
throw new Error('Invalid credentials');
}
keys.forEach((key) => throttle.recordSuccess(key));
}import { CHANNELS, MockChannelAdapter, NotificationService } from '@charles2ke/platform-shared/notifications';
const notifications = new NotificationService({
adapters: {
[CHANNELS.EMAIL]: new MockChannelAdapter({ channel: CHANNELS.EMAIL }),
[CHANNELS.PUSH]: new MockChannelAdapter({ channel: CHANNELS.PUSH })
}
});
await notifications.send({
channels: [CHANNELS.EMAIL, CHANNELS.PUSH],
to: { email: '[email protected]', userId: 'user-123' },
subject: 'Welcome {{name}}',
body: 'Hi {{name}}, your account is ready.',
variables: { name: 'Charles' }
});import { InMemoryNotificationScheduler, NotificationService } from '@charles2ke/platform-shared/notifications';
const scheduler = new InMemoryNotificationScheduler(); // swap for a queue-backed adapter
const notifications = new NotificationService({
adapters,
scheduler,
maxScheduleAttempts: 4,
retryDelayMs: 30_000,
retryBackoffFactor: 2,
maxRetryDelayMs: 15 * 60_000,
deadLetterStore, // durable record of exhausted retries, replayable later
onDeadLetter: ({ notification, attempts, reason }) => auditLog.write({ notification, attempts, reason })
});
await notifications.schedule(reminder, new Date(Date.now() + 60_000));
// Cron/worker loop
const { processed, retried, deadLettered, pending } = await notifications.dispatchScheduled();
// Cancel queued deliveries when the underlying event changes.
await notifications.listScheduled();
await notifications.cancelScheduled(reminder.id);import { createBackgroundWorker } from '@charles2ke/platform-shared/runtime';
const worker = createBackgroundWorker({
name: 'profile-reindex',
handler: async ({ trigger }) => reindexProfiles({ trigger }),
intervalMs: 5 * 60_000,
timeoutMs: 60_000,
runOnStart: true,
logger,
onError: (error) => metrics.increment('profile_reindex_failed', { code: error.code })
});
worker.start(); // long-running process
// On demand: admin endpoint, CLI command, or external cron trigger.
const run = await worker.runOnce({ trigger: 'admin', requestedBy: principal.id });
if (run.status === 'skipped') {
// a run was already in flight
}
worker.getStats(); // { runs, failures, skipped, lastDurationMs, ... }
worker.stop();- Install or vendor this package in the consuming app.
- Load app-specific env vars with
loadConfig()or the app's existing config layer. - Wrap
createAuthGuard()in the app's router middleware layer. - Replace in-memory stores/adapters with app-owned persistence and provider adapters by extending
AccountStore,ProfileStore,ChannelAdapter,NotificationScheduler, orTokenRevocationStore(or supplying objects with the same methods). - Keep domain-specific behavior in the app; keep shared identity/profile/notification contracts here.
See examples/social.js, examples/travel.js, examples/workout.js, and examples/basa.js for starting points. They are executable stubs covered by tests/examples.test.js:
| Example | Shows |
|---|---|
social.js |
Login, guarded routes (including Express middleware), refresh-token rotation with replay detection, session introspection, session/single-token/global logout, role registry. |
workout.js |
Role-derived and inherited-role permissions, authorize() in service code, scheduled push nudges with exponential backoff, cancellation, and dead lettering. |
travel.js |
Immediate and scheduled multi-channel reminders drained by createNotificationWorker(), plus pending listing, cancellation, dead-letter capture, and replay. |
basa.js |
RBAC-guarded order operations, policy-enforced profile CRUD (createAccessPolicy()), and scheduled email/SMS order updates with pending listing and cancellation. |
- Use shared JWT issuing/verification for login and protected social routes.
- Map existing user profile fields to the canonical profile model.
- Replace local notification calls with
NotificationServicechannel adapters. - Add social-specific persistent account/profile store adapters.
- Verify API/mobile tokens with
createAuthGuard()and travel audience settings. - Store traveler locale/timezone/preferences through
ProfileService. - Route trip reminders through email and push adapters.
- Add provider-specific retry/queue integration around notification delivery.
- Guard workout read/write routes with shared permission names.
- Use profile preferences for units, timezone, and notification settings.
- Send workout nudges through push notifications.
- Add persistent adapters for workout user profiles.
- Align customer account auth with shared token claims and RBAC scaffolding.
- Normalize customer contact metadata with the canonical profile model.
- Route order updates through email/SMS adapters.
- Add commerce-specific permissions and persistent stores.
This repository uses Node's built-in test runner and has no external runtime dependencies.
npm test # unit and example tests
npm run build # syntax checks across src, tests, examples, scripts
npm run check # build + test (run this before opening a pull request)
npm run test:coverage # tests with Node's experimental coverage report
npm run audit # production dependency audit (high severity and above)npm run build performs syntax checks across source, tests, examples, and scripts.
Type declarations are generated from the JSDoc in src/ with the typescript dev dependency and are not committed:
npm run types # emit .d.ts files into types/ (also runs on npm install/prepare and before publish)
npm run check:types # type-check the TypeScript consumer tests against the JSDoc-derived typesEvery exports subpath has a matching types entry, so import { verifyToken } from '@charles2ke/platform-shared/auth' is typed in TypeScript and editor tooling.
- The package has no runtime dependencies and no build-time dependencies, so there is no
transitive supply-chain surface to patch. A committed
package-lock.jsonkeeps installs reproducible, andnpm audit --omit=dev --audit-level=highruns in CI and reports no advisories. engines.nodeis>=22; CI verifies Node 22 and Node 24.- Dependabot watches npm and GitHub Actions weekly, and all workflows pin the latest stable major versions of the actions they use.
| Control | Where | Trigger |
|---|---|---|
| Tests + syntax checks on Node 22/24 | .github/workflows/ci.yml |
push, pull request, manual |
| Production dependency audit | .github/workflows/ci.yml |
push, pull request, manual |
| CodeQL analysis | GitHub code scanning default setup | push, pull request, weekly |
| Dependency version updates | .github/dependabot.yml |
weekly |
| Documentation site deploy | .github/workflows/pages.yml |
push to main touching docs/ |
Workflows declare least-privilege permissions and check out without persisting credentials.
- HS256 signatures are compared in constant time, and
issuer,audience, and expiry claims are verified explicitly. - Refresh-token rotation detects replay and revokes the affected sessions.
- Revocation stores are synchronous, so a guard cannot be bypassed by an unawaited promise.
- Access policies are action-keyed, so the same requirements apply to HTTP routes, jobs, and queue consumers.
- Errors are structured
PlatformErrors, andtoHttpErrorResponse()maps them to a consistent{ status, body }envelope; it does not sanitizeerror.messageorPlatformError.details, so callers must avoid putting sensitive data in either.
-
PLATFORM_JWT_SECRETis at least 32 random characters, stored in a secret manager, and rotated on a schedule. - Token revocation is backed by a shared store or cache when running more than one instance.
- Access-token TTLs stay short and refresh rotation is enabled.
- TLS terminates in front of every guarded service.
- Dead-letter records are monitored and replayed after provider outages.
See SECURITY.md for the vulnerability reporting process and the full security model.
docs/index.html is a dependency-free single-page site covering every module, feature, and
hardening control in this repository. It is deployed to GitHub Pages by
.github/workflows/pages.yml whenever docs/ changes on main. Enable it once under
Settings → Pages → Build and deployment → GitHub Actions. Update the site alongside README.md
whenever public behavior changes.
- JWT support is implemented with built-in Node
cryptoand HS256 to avoid introducing dependencies before provider decisions are made. - Guards are request-shape based instead of Express/Fastify/Next specific, so each app can adapt them to its framework.
- Store and channel adapters are constructor-injected to make persistence and provider migration explicit.
- Notification scheduling supports in-memory queuing for local workflow depth; downstream apps should still connect production queues/schedulers through adapters.
- Token revocation is an injectable synchronous store so guards remain synchronous; production apps back it with a cache warmed from their session store.
- Access and refresh tokens carry a shared session id so logout and refresh-token replay can close a whole login without revoking every session for a subject.
- Authorization is expressed as action-keyed policies rather than hard-coded checks, so the same requirements apply to HTTP routes, background jobs, and queue consumers.
- Framework and provider glue lives in
src/adapters/so the core modules stay framework-light while consumer repos get working starting points.
Recommended next steps:
- Publish the package or configure each app to consume it from GitHub.
- Add persistent adapters for each app's user/account/profile data store.
- Add real email, SMS, and push adapters behind the existing notification channel interface (
HttpChannelAdaptercovers HTTP providers). - Standardize role and permission names across
social,travel,workout, andbasa.