npm package: @objectifthunes/sandstone-sdk
Description: Contracts-first backend toolkit for TypeScript + PostgreSQL + GraphQL. Framework-agnostic, vendor-agnostic.
Author: ObjectifThunes
Latest version: 1.3.6

# @objectifthunes/sandstone-sdk

[![codecov](https://codecov.io/gh/objectifthunes/sandstone-sdk/graph/badge.svg)](https://codecov.io/gh/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 port
```

## Install

```bash
pnpm add @objectifthunes/sandstone-sdk
```

Then install only the adapters you need:

```bash
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                                   # logging
```

## Quick Start

```ts
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('user@example.com')
const { user, tokens: pair, isNewUser } = await auth.verifyCode('user@example.com', '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** — `sql` tagged template, `select`/`insert`/`update`/`del` builders
- **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

### Email
- 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 clients
- `publish(channel, event, data)` — broadcast to all subscribers on a channel
- `getChannels()` / `getSubscriberCount(channel)` / `getSubscribers(channel)` — presence queries
- `authorizeChannel(socketId, channel, userData?)` — sign private/presence channel auth tokens
- `disconnect?(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?)`
- `FlagContext` for 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)
- `manage` action expands to all actions on a resource type
- DB-backed via the built-in `authorization_permissions` table (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 resource
- `log(entry)` / `query(filters, pagination)` / `count(filters)` — structured entries with actor, action, resource, and metadata
- DB-backed via the built-in `audit_log` table (migration 017) with indexes on `actor_id`, `resource_type`, and `created_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 an `HttpHandler`
- 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 `HttpHandler` functions

## NestJS Integration

```ts
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: 'noreply@app.com' },
      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:

```ts
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.

```ts
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('test@test.com')
const code = email.lastCodeSent()
const { user } = await auth.verifyCode('test@test.com', 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