https://github.com/tinyland-inc/tinyland-auth-pg
PostgreSQL storage adapter for @tummycrypt/tinyland-auth (Neon + Drizzle)
https://github.com/tinyland-inc/tinyland-auth-pg
auth bazel-module persistence postgres pulse-live-authority
Last synced: 7 days ago
JSON representation
PostgreSQL storage adapter for @tummycrypt/tinyland-auth (Neon + Drizzle)
- Host: GitHub
- URL: https://github.com/tinyland-inc/tinyland-auth-pg
- Owner: tinyland-inc
- Created: 2026-03-22T21:53:03.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-05-27T16:27:00.000Z (about 1 month ago)
- Last Synced: 2026-05-27T18:18:52.098Z (about 1 month ago)
- Topics: auth, bazel-module, persistence, postgres, pulse-live-authority
- Language: TypeScript
- Size: 147 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
Awesome Lists containing this project
README
# @tummycrypt/tinyland-auth-pg
PostgreSQL storage adapter for [@tummycrypt/tinyland-auth](https://github.com/Jesssullivan/tinyland-auth), backed by [Drizzle ORM](https://orm.drizzle.team) with driver-agnostic construction and multi-tenant scoping.
Supports Neon HTTP, `postgres.js`, and `node-postgres`. Use `createNodePgStorageAdapter()`
when you want the package to own a `pg.Pool`, or `createPgStorageAdapter({ db })`
when you already have a pre-built Drizzle client.
> **0.2.0 is a breaking release.** Every adapter method now takes `tenantId: string`
> as its first parameter. Every row-bearing table has `tenant_id uuid NOT NULL`.
> See [`CHANGELOG.md`](./CHANGELOG.md#020--2026-04-17) for the full migration guide.
## Installation
```bash
npm install @tummycrypt/tinyland-auth-pg
# or
pnpm add @tummycrypt/tinyland-auth-pg
```
### Peer Dependencies
```bash
npm install @tummycrypt/tinyland-auth
```
## Quick Start (0.2.0+)
### With `node-postgres` (owned pool; recommended for CNPG / local PG)
```typescript
import { createNodePgStorageAdapter } from '@tummycrypt/tinyland-auth-pg';
const adapter = createNodePgStorageAdapter({
connectionString: process.env.DATABASE_URL!,
poolConfig: { max: 10 },
});
const user = await adapter.getUser('', '');
```
### With `postgres.js` (recommended for PgBouncer transaction mode)
```typescript
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
import { createPgStorageAdapter } from '@tummycrypt/tinyland-auth-pg';
import * as schema from '@tummycrypt/tinyland-auth-pg/schema';
// prepare: false is required when talking to PgBouncer in transaction mode
const sql = postgres(process.env.DATABASE_URL!, { prepare: false, max: 10 });
const db = drizzle(sql, { schema });
const storage = createPgStorageAdapter({ db });
// Every method takes tenantId first
const user = await storage.getUser('', '');
```
### With Neon HTTP (legacy, still supported)
```typescript
import { createPgStorageAdapter } from '@tummycrypt/tinyland-auth-pg';
const storage = createPgStorageAdapter({
connectionString: process.env.DATABASE_URL!,
sessionMaxAge: 7 * 24 * 60 * 60 * 1000, // 7 days (default)
});
const user = await storage.getUser('', '');
```
### Bootstrap deploy users
Use `bootstrapUsers()` when an app needs to seed tenant-scoped admin users
during deploy/startup without duplicating raw SQL.
```typescript
import { bootstrapUsers } from '@tummycrypt/tinyland-auth-pg';
import { hashPassword } from '@tummycrypt/tinyland-auth';
await bootstrapUsers({
pool, // existing pg.Pool, owned by the caller
tenantId,
users: [
{
handle: 'jess',
email: 'jess@example.com',
displayName: 'Jess Sullivan',
pin: '123456',
role: 'admin',
},
],
});
```
The helper accepts an existing tenant-scoped `storage` adapter, an existing
`pg.Pool`, or a `connectionString`. Existing users are updated by default so
password, role, email, and display name changes converge on rerun. Pass
`updateExisting: false` to leave existing users untouched.
### Row-Level Security recommended pattern
Pair the adapter with a `withTenant` wrapper at the app-layer so every query
also flows through an RLS `SET LOCAL`. The explicit `tenantId` param is your
first line of defense; the `SET LOCAL` is belt-and-suspenders if a call-site
ever forgets to scope.
```typescript
await sql.begin(async (tx) => {
await tx`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
return storage.getUser(tenantId, userId);
});
```
## Schema Overview
The package exports six Drizzle schema modules, each targeting a specific domain:
| Export | Schema | Tables | Purpose |
|--------|--------|--------|---------|
| `./schema` | `auth` | users, sessions, totp_secrets, backup_codes, invitations, audit_events | Authentication and authorization |
| `./content-schema` | `public` | business_profile, services, business_hours, reviews, practitioners | CMS content |
| `./booking-schema` | `public` | clients, bookings, time_blocks, business_hours_overrides, slot_reservations | Scheduling and appointments |
| `./giftcert-schema` | `public` | gift_certificates, gift_certificate_redemptions | Gift certificate tracking |
| `./intake-schema` | `public` | intake_submissions | Patient intake forms |
| `./business-schema` | `public` | (composite re-export) | Business domain aggregation |
### Auth Schema (`auth.*`)
- **users** -- Admin users with roles (viewer, editor, business_owner, developer), PIN hashes, TOTP state, onboarding tracking
- **sessions** -- DB-backed sessions with HMAC-signed UUIDs, metadata (IP, user agent), configurable TTL
- **totp_secrets** -- AES-encrypted TOTP secrets, linked to users
- **backup_codes** -- Bcrypt-hashed one-time recovery codes
- **invitations** -- Email-based user invitations with token + expiry
- **audit_events** -- Timestamped auth event log (login, logout, failed attempts, role changes)
### Booking Schema (`public.*`)
- **clients** -- Client directory (name, email, phone, notes)
- **bookings** -- Appointment records with status (confirmed, cancelled, completed, no_show), payment tracking
- **time_blocks** -- Practitioner availability blocks (break, vacation, hold)
- **business_hours_overrides** -- Date-specific hour overrides
- **slot_reservations** -- Temporary slot holds during booking flow (TTL-based)
## Drizzle Migrations
Push schema changes directly (development):
```bash
# Auth schema
DATABASE_URL="postgresql://..." pnpm db:push
# Public schema (booking, content)
DATABASE_URL="postgresql://..." npx drizzle-kit push --config=drizzle.public.config.ts
```
Generate migration files (production):
```bash
DATABASE_URL="postgresql://..." pnpm db:generate
DATABASE_URL="postgresql://..." pnpm db:migrate
```
## API Reference
### `createPgStorageAdapter(config: PgStorageConfig): PgStorageAdapter`
Factory function that returns a Pattern B tenant-scoped adapter.
```typescript
type PgStorageConfig =
| { db: Database; sessionMaxAge?: number } // driver injection (recommended)
| { connectionString: string; sessionMaxAge?: number }; // legacy neon-http
type Database =
| NeonHttpDatabase
| NodePgDatabase
| PostgresJsDatabase;
```
Both branches validate their input at construction time and throw loudly on
nullish `db` or empty `connectionString` rather than deferring to the first
query.
### `createNodePgStorageAdapter(config: NodePgStorageConfig): NodePgStorageAdapter`
Factory function that constructs and owns a `pg.Pool` for standard PostgreSQL.
```typescript
interface NodePgStorageConfig {
connectionString: string;
sessionMaxAge?: number;
poolConfig?: PoolConfig;
closeOnDispose?: boolean; // default true
}
```
### `bootstrapUsers(config: BootstrapUsersConfig): Promise`
Idempotently seeds or updates tenant-scoped auth users through the adapter
boundary. No raw SQL is required at the consumer.
```typescript
type BootstrapUsersConfig = {
tenantId: string;
users: Array<{
handle: string;
email: string;
displayName?: string;
pin?: string; // or the password field, or a precomputed hash
role: AdminRole;
}>;
updateExisting?: boolean; // default true
} & (
| { storage: BootstrapUserStorage }
| { pool: Pool }
| { connectionString: string; poolConfig?: PoolConfig }
);
```
Each user must provide one credential source: `pin`, a plaintext password, or
a precomputed password hash. A custom `passwordHasher` may be supplied; when it
is omitted, the helper uses `@tummycrypt/tinyland-auth`'s `hashPassword`.
### `PgStorageAdapter`
Every method accepts `tenantId: string` as its **first parameter** and returns
`TenantScoped` where the domain type carries `tenantId`. Key methods:
#### User Management
- `getUser(tenantId, id): Promise | null>`
- `getUserByHandle(tenantId, handle): Promise | null>`
- `getUserByEmail(tenantId, email): Promise | null>`
- `createUser(tenantId, user): Promise>`
- `updateUser(tenantId, id, updates): Promise>`
- `deleteUser(tenantId, id): Promise`
- `getAllUsers(tenantId): Promise[]>`
- `hasUsers(tenantId): Promise`
#### Session Management
- `createSession(tenantId, userId, metadata?): Promise>`
- `getSession(tenantId, sessionId): Promise | null>`
- `updateSession(tenantId, sessionId, updates): Promise>`
- `deleteSession(tenantId, sessionId): Promise`
- `deleteUserSessions(tenantId, userId): Promise`
- `getSessionsByUser(tenantId, userId): Promise[]>`
- `getAllSessions(tenantId): Promise[]>`
- `cleanupExpiredSessions(tenantId): Promise`
#### TOTP / Backup Codes
- `saveTOTPSecret(tenantId, handle, secret): Promise`
- `getTOTPSecret(tenantId, handle): Promise`
- `deleteTOTPSecret(tenantId, handle): Promise`
- `saveBackupCodes(tenantId, userId, codes): Promise`
- `getBackupCodes(tenantId, userId): Promise`
- `deleteBackupCodes(tenantId, userId): Promise`
#### Invitations
- `createInvitation(tenantId, invitation): Promise>`
- `getInvitation(tenantId, token): Promise | null>`
- `getInvitationById(tenantId, id): Promise | null>`
- `getAllInvitations(tenantId): Promise[]>`
- `getPendingInvitations(tenantId): Promise[]>`
- `updateInvitation(tenantId, token, updates): Promise>`
- `deleteInvitation(tenantId, token): Promise`
- `cleanupExpiredInvitations(tenantId): Promise`
#### Audit Log
- `logAuditEvent(tenantId, event): Promise`
- `getAuditEvents(tenantId, filters?): Promise`
> **Interface note:** the class does not `implements IStorageAdapter` from
> `@tummycrypt/tinyland-auth@0.2.x` because the peer package predates Pattern B.
> An interface uplift will ship with tinyland-auth 0.3.0 and this adapter will
> re-implement it then. Until then, consume the concrete class or type against
> the exported method signatures directly.
### `NodePgStorageAdapter`
Subclass of `PgStorageAdapter` that exposes its owned `pool: Pool` and closes
that pool by default when `adapter.close()` is called.
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `DATABASE_URL` | Yes | PostgreSQL connection string for Neon, CNPG, local PG, or other supported deployments |
## Development
```bash
pnpm install
pnpm test # Run tests
pnpm build # Compile TypeScript
pnpm test:watch # Watch mode
```
### Nix
```bash
nix develop # Enter dev shell with Node 20 + pnpm + tsc
```
## License
MIT