@objectifthunes/sandstone-sdk
v1.3.6
Published
Contracts-first backend toolkit for TypeScript + PostgreSQL + GraphQL. Framework-agnostic, vendor-agnostic.
Maintainers
Keywords
Readme
@objectifthunes/sandstone-sdk
Contracts-first backend toolkit for TypeScript + PostgreSQL + GraphQL. Hexagonal architecture. Framework-agnostic, vendor-agnostic.
Why
Every Node.js backend rewrites the same boilerplate: database connections, auth, email, payments, GraphQL wiring, testing setup. And every framework locks you into its world.
This package defines TypeScript interfaces (ports) for every infrastructure concern and ships pure logic that depends only on those interfaces. Concrete implementations (adapters) are separate subpath exports with optional peer dependencies. Swap any piece without touching the rest.
The rule: if you delete any adapter and the core still compiles, the architecture is correct.
Architecture
ports/driving/ ← inbound (HttpHandler)
ports/driven/ ← outbound (DatabaseClient, Logger, TokenSigner, EmailTransport, ...)
src/auth|db|email| ← domain logic (depends only on ports)
adapters/ ← concrete implementations (pg, jose, stripe, supabase, r2, ...)
testing/in-memory/ ← test adapters for every portInstall
pnpm add @objectifthunes/sandstone-sdkThen install only the adapters you need:
pnpm add pg jose resend # database, jwt, email
pnpm add stripe # payments
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner # storage
pnpm add pino # loggingQuick Start
import { createAuth, createGraphQLHandler, createContextFactory, runMigrations } from '@objectifthunes/sandstone-sdk'
import { createPgClient } from '@objectifthunes/sandstone-sdk/pg'
import { createJoseSigner } from '@objectifthunes/sandstone-sdk/jose'
import { createResendTransport } from '@objectifthunes/sandstone-sdk/resend'
import { createPinoLogger } from '@objectifthunes/sandstone-sdk/pino'
const db = createPgClient({ connectionString: process.env.DATABASE_URL })
const tokens = createJoseSigner({ secret: process.env.JWT_SECRET })
const email = createResendTransport({ apiKey: process.env.RESEND_API_KEY })
const logger = createPinoLogger({ level: 'info' })
await runMigrations(db, { includeBuiltIn: true })
const auth = createAuth({ db, email, tokens, logger, otp: { length: 6, expiresIn: '10m' } })
await auth.sendCode('[email protected]')
const { user, tokens: pair, isNewUser } = await auth.verifyCode('[email protected]', '482917')Ports (Interfaces)
The core defines 18 interfaces. Every module depends only on these.
| Port | Direction | What it does |
|---|---|---|
| HttpHandler | Driving (inbound) | (HttpRequest) => Promise<HttpResponse> |
| DatabaseClient | Driven (outbound) | Query, transaction, health check |
| Logger | Driven (outbound) | Structured logging with child loggers |
| TokenSigner | Driven (outbound) | Sign/verify JWT tokens |
| EmailTransport | Driven (outbound) | Send emails |
| SmsTransport | Driven (outbound) | Send SMS to phone numbers (E.164 format) |
| PushTransport | Driven (outbound) | Send push notifications to devices |
| RealtimeProvider | Driven (outbound) | Publish events, presence, channel auth, disconnect |
| PaymentProvider | Driven (outbound) | Customers, subscriptions, invoices, webhooks |
| StorageProvider | Driven (outbound) | Upload, download, signed URLs |
| CacheProvider | Driven (outbound) | Get/set/delete with TTL |
| PasswordHasher | Driven (outbound) | Hash and verify passwords |
| OAuthProvider | Driven (outbound) | Get OAuth redirect URL, handle callback |
| Tracer | Driven (outbound) | Start spans, trace async operations |
| SearchProvider | Driven (outbound) | Index, search, delete, vector search |
| QueueProvider | Driven (outbound) | Enqueue, process, retry, dead letter queue |
| FeatureFlagProvider | Driven (outbound) | Check flags, get values, targeting rules, percentage rollouts |
| AuthorizationProvider | Driven (outbound) | Resource-level RBAC: can, authorize, grant, revoke, listPermissions |
Adapters
| Subpath | Port | Peer dep | What |
|---|---|---|---|
| ./pg | DatabaseClient | pg | PostgreSQL via pg driver |
| ./supabase | DatabaseClient | pg | Supabase PostgreSQL (SSL + pooler defaults) |
| ./jose | TokenSigner | jose | JWT — HS256, RS256, or ES256 |
| ./pino | Logger | pino | Structured logging via Pino |
| ./nodemailer | EmailTransport | nodemailer | SMTP email |
| ./resend | EmailTransport | resend | Resend API |
| ./twilio | SmsTransport | (none, uses fetch) | Twilio Programmable Messaging |
| ./sns | SmsTransport | @aws-sdk/client-sns | AWS SNS SMS |
| ./fcm | PushTransport | (none, uses fetch + jose) | Firebase Cloud Messaging (FCM v1) |
| ./apns | PushTransport | (none, uses node:http2) | Apple Push Notification service (APNs) |
| ./socketio | RealtimeProvider | socket.io | Socket.io — bidirectional WebSocket events |
| ./pusher | RealtimeProvider | (none, uses fetch) | Pusher Channels — managed WebSocket events |
| ./ably | RealtimeProvider | (none, uses fetch) | Ably — managed pub/sub and presence |
| ./argon2 | PasswordHasher | argon2 | Argon2id password hashing |
| ./stripe | PaymentProvider | stripe | Stripe SDK |
| ./paddle | PaymentProvider | (none, uses fetch) | Paddle Billing API + webhook verification |
| ./lemonsqueezy | PaymentProvider | (none, uses fetch) | Lemon Squeezy API + webhook HMAC |
| ./revenuecat | PaymentProvider | (none, uses fetch) | RevenueCat API + webhook HMAC |
| ./s3 | StorageProvider | @aws-sdk/client-s3 | S3-compatible storage |
| ./r2 | StorageProvider | @aws-sdk/client-s3 | Cloudflare R2 (S3-compat with R2 defaults) |
| ./local-storage | StorageProvider | (none) | Local filesystem |
| ./memory | CacheProvider | (none) | In-process LRU cache with TTL |
| ./redis | CacheProvider | ioredis | Redis over TCP via ioredis |
| ./upstash | CacheProvider | @upstash/redis | Upstash Redis (HTTP) |
| ./oauth-google | OAuthProvider | (none, uses fetch) | Google OAuth 2.0 |
| ./oauth-github | OAuthProvider | (none, uses fetch) | GitHub OAuth 2.0 |
| ./oauth-apple | OAuthProvider | (none, uses fetch) | Sign in with Apple |
| ./oauth-discord | OAuthProvider | (none, uses fetch) | Discord OAuth 2.0 |
| ./otel | Tracer | @opentelemetry/api | OpenTelemetry tracing |
| ./meilisearch | SearchProvider | meilisearch | Meilisearch full-text + vector search |
| ./typesense | SearchProvider | typesense | Typesense full-text + vector search |
| ./pg-search | SearchProvider | pg | PostgreSQL full-text + pgvector search |
| ./bullmq | QueueProvider | bullmq | BullMQ — Redis-based job queue |
| ./pg-boss | QueueProvider | pg-boss | pg-boss — PostgreSQL-based job queue |
| ./node-cron | TickSource | node-cron | Precise cron scheduling |
| ./pg-flags | FeatureFlagProvider | pg | PostgreSQL-backed feature flags with targeting rules |
| ./env-flags | FeatureFlagProvider | (none) | Environment variable feature flags — zero infra |
| ./launchdarkly | FeatureFlagProvider | @launchdarkly/node-server-sdk | LaunchDarkly SDK integration |
| ./pg-authz | AuthorizationProvider | pg | PostgreSQL-backed RBAC (migration 018) |
| ./casbin | AuthorizationProvider | casbin | Casbin policy engine — RBAC, ABAC, domain models |
| ./adapters | HttpHandler | (optional peers) | Express, Fastify, Hono, raw Node.js |
| ./nestjs | All ports | @nestjs/common | NestJS dynamic module + injection tokens |
| ./dev | All ports | (none) | In-memory dev adapter — mirrors all production factory names. Zero config. |
| ./testing | All ports | (none) | In-memory implementations for every port |
Modules
Database
- Query builder —
sqltagged template,select/insert/update/delbuilders - Repository factory — generic CRUD with pagination, soft delete, typed queries
- Migrations — plain SQL files, built-in tables for auth/payments/email
Auth
- OTP — 6-digit code sent to email, SHA-256 hashed in DB, single-use, rate-limited
- Password — register, login, change password, reset via OTP; exponential lockout on failed attempts
- OAuth — Google, GitHub, Apple, Discord (CSRF-protected state parameter); extensible to any provider via
OAuthProvider - JWT access + refresh tokens with DB-backed sessions
- Role-based guards
GraphQL
- Code-first schema helpers, custom scalars (DateTime, JSON, UUID, Email)
- Context factory with automatic auth extraction and dataloaders
- Framework-agnostic HTTP handler
- Template engine (HTML with
{{variable}}interpolation) - DB-backed queue with retries
- Built-in templates: login code, welcome, invoice
SMS & Push Notifications
SmsTransport— send SMS to E.164 phone numbers (send({ to, body }))PushTransport— send single or bulk push notifications to device tokens (send,sendMany)- SMS adapters: Twilio (
./twilio, fetch-based), AWS SNS (./sns,@aws-sdk/client-sns) - Push adapters: FCM v1 (
./fcm, fetch + jose for OAuth 2.0), APNs (./apns, node:http2 + JWT)
Real-Time
RealtimeProvider— publish events to channels, query presence, authorize private channels, disconnect clientspublish(channel, event, data)— broadcast to all subscribers on a channelgetChannels()/getSubscriberCount(channel)/getSubscribers(channel)— presence queriesauthorizeChannel(socketId, channel, userData?)— sign private/presence channel auth tokensdisconnect?(socketId)— optional: force-disconnect a specific client- Adapters: Socket.io (
./socketio,socket.io), Pusher (./pusher, fetch-based), Ably (./ably, fetch-based)
Payments
- Customer sync (DB + provider), subscription lifecycle, webhook routing, invoice tracking
- Timing-safe webhook signature verification
Search
- Keyword search with filters, facets, highlights, sorting, and pagination
- Optional vector/semantic search with hybrid keyword+vector scoring
- Adapters: Meilisearch, Typesense, PostgreSQL full-text search (+ pgvector)
Feature Flags
isEnabled(flag, context?),getValue(flag, defaultValue, context?),getAllFlags(context?)FlagContextfor per-user or per-tenant targeting (userId,tenantId,attributes)- Targeting rules by user ID list, tenant ID list, or percentage rollout
- Adapters: PostgreSQL (
./pg-flags), environment variables (./env-flags), LaunchDarkly (./launchdarkly)
Authorization
- Resource-level RBAC via
AuthorizationProvider—can,authorize,grant,revoke,listPermissions - Subject + action + resource type + optional resource ID (type-level or instance-level)
manageaction expands to all actions on a resource type- DB-backed via the built-in
authorization_permissionstable (migration 018) with'*'sentinel for type-level grants - Adapters: PostgreSQL (
./pg-authz), Casbin (./casbin)
Audit Logging
createAuditLogger()— append-only log of who did what, when, and to which resourcelog(entry)/query(filters, pagination)/count(filters)— structured entries with actor, action, resource, and metadata- DB-backed via the built-in
audit_logtable (migration 017) with indexes onactor_id,resource_type, andcreated_at
Background Jobs & Scheduling
- Durable job queue with retries, backoff, dead letter queue
- Composable scheduler with pluggable tick sources (node-cron, setInterval)
- Adapters: BullMQ (Redis), pg-boss (PostgreSQL)
Dashboard
createDashboard(app, options?)— self-contained HTML dashboard served as anHttpHandler- Shows: port wiring map, registered routes, health status, email queue depth, scheduler tasks, feature flags, realtime channels, recent audit trail, authorization stats
- Dark theme, auto-refresh via polling, optional shared-secret auth guard
- No React, no build step — pure inline HTML/CSS/JS; mount at any route
HTTP
- CORS middleware, rate limiter, health checks — all return
HttpHandlerfunctions
NestJS Integration
import { TpgModule } from '@objectifthunes/sandstone-sdk/nestjs'
import { createSupabaseClient } from '@objectifthunes/sandstone-sdk/supabase'
import { createJoseSigner } from '@objectifthunes/sandstone-sdk/jose'
import { createResendTransport } from '@objectifthunes/sandstone-sdk/resend'
import { createRevenueCatProvider } from '@objectifthunes/sandstone-sdk/revenuecat'
import { createR2Storage } from '@objectifthunes/sandstone-sdk/r2'
import { createUpstashCache } from '@objectifthunes/sandstone-sdk/upstash'
import { createPinoLogger } from '@objectifthunes/sandstone-sdk/pino'
@Module({
imports: [
TpgModule.forRoot({
db: createSupabaseClient({ connectionString: process.env.DATABASE_URL }),
logger: createPinoLogger({ level: 'info' }),
tokens: createJoseSigner({ secret: process.env.JWT_SECRET }),
email: { transport: createResendTransport({ apiKey: '...' }), from: '[email protected]' },
auth: { otp: { length: 6, expiresIn: '10m' } },
payments: { provider: createRevenueCatProvider({ apiKey: '...', webhookSecret: '...' }) },
storage: createR2Storage({ accountId: '...', accessKeyId: '...', secretAccessKey: '...', bucket: '...' }),
cache: createUpstashCache({ url: '...', token: '...' }),
runMigrations: { includeBuiltIn: true },
}),
],
})
export class AppModule {}Then inject anywhere:
import { Inject } from '@nestjs/common'
import { TPG_DB, TPG_AUTH, TPG_TOKENS } from '@objectifthunes/sandstone-sdk/nestjs'
import type { DatabaseClient, Auth } from '@objectifthunes/sandstone-sdk'
@Injectable()
export class MyService {
constructor(
@Inject(TPG_DB) private db: DatabaseClient,
@Inject(TPG_AUTH) private auth: Auth,
) {}
}Testing
In-memory implementations for every port ship with the package. Real logic, zero external dependencies.
import { createAuth } from '@objectifthunes/sandstone-sdk'
import {
createInMemoryDatabase,
createInMemoryEmail,
createInMemoryTokenSigner,
createInMemoryLogger,
} from '@objectifthunes/sandstone-sdk/testing'
const db = createInMemoryDatabase()
const email = createInMemoryEmail()
const tokens = createInMemoryTokenSigner()
const logger = createInMemoryLogger()
const auth = createAuth({ db, email, tokens, logger, otp: { length: 6, expiresIn: '10m' } })
await auth.sendCode('[email protected]')
const code = email.lastCodeSent()
const { user } = await auth.verifyCode('[email protected]', code!)Hard Dependencies
Only three: graphql (the spec), zod (config validation), dataloader (batching). Everything else is an optional peer dep of an adapter.
Security Considerations
JWT algorithm: The jose adapter supports HS256 (symmetric, default), RS256, and ES256. HS256 is appropriate for single-service deployments where the same process signs and verifies tokens. For multi-service deployments where services verify tokens without sharing the signing secret, use RS256 or ES256 by passing { privateKey, publicKey, algorithm: 'RS256' } (or 'ES256') to createJoseSigner.
CSRF: The auth module protects OAuth flows with state parameters. Password login and OTP verification do not include CSRF tokens. If your application uses cookie-based sessions (rather than Bearer tokens), you must add CSRF protection at the framework level (e.g., csurf for Express, built-in CSRF for NestJS).
Query builder: Column names, table names, and identifiers passed to the query builder are validated against /^[a-zA-Z_][a-zA-Z0-9_]*$/. Never pass user input as an identifier — only use hardcoded strings.
License
MIT
