{"id":51189054,"url":"https://github.com/tinyland-inc/tinyland-auth-pg","last_synced_at":"2026-06-27T13:03:03.611Z","repository":{"id":346266197,"uuid":"1189011325","full_name":"tinyland-inc/tinyland-auth-pg","owner":"tinyland-inc","description":"PostgreSQL storage adapter for @tummycrypt/tinyland-auth (Neon + Drizzle)","archived":false,"fork":false,"pushed_at":"2026-05-27T16:27:00.000Z","size":151,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-27T18:18:52.098Z","etag":null,"topics":["auth","bazel-module","persistence","postgres","pulse-live-authority"],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tinyland-inc.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-22T21:53:03.000Z","updated_at":"2026-05-27T16:27:05.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/tinyland-inc/tinyland-auth-pg","commit_stats":null,"previous_names":["tinyland-inc/tinyland-auth-pg"],"tags_count":6,"template":false,"template_full_name":null,"purl":"pkg:github/tinyland-inc/tinyland-auth-pg","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tinyland-inc%2Ftinyland-auth-pg","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tinyland-inc%2Ftinyland-auth-pg/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tinyland-inc%2Ftinyland-auth-pg/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tinyland-inc%2Ftinyland-auth-pg/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tinyland-inc","download_url":"https://codeload.github.com/tinyland-inc/tinyland-auth-pg/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tinyland-inc%2Ftinyland-auth-pg/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34854185,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-27T02:00:06.362Z","response_time":126,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["auth","bazel-module","persistence","postgres","pulse-live-authority"],"created_at":"2026-06-27T13:03:02.883Z","updated_at":"2026-06-27T13:03:03.606Z","avatar_url":"https://github.com/tinyland-inc.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# @tummycrypt/tinyland-auth-pg\n\nPostgreSQL 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.\n\nSupports Neon HTTP, `postgres.js`, and `node-postgres`. Use `createNodePgStorageAdapter()`\nwhen you want the package to own a `pg.Pool`, or `createPgStorageAdapter({ db })`\nwhen you already have a pre-built Drizzle client.\n\n\u003e **0.2.0 is a breaking release.** Every adapter method now takes `tenantId: string`\n\u003e as its first parameter. Every row-bearing table has `tenant_id uuid NOT NULL`.\n\u003e See [`CHANGELOG.md`](./CHANGELOG.md#020--2026-04-17) for the full migration guide.\n\n## Installation\n\n```bash\nnpm install @tummycrypt/tinyland-auth-pg\n# or\npnpm add @tummycrypt/tinyland-auth-pg\n```\n\n### Peer Dependencies\n\n```bash\nnpm install @tummycrypt/tinyland-auth\n```\n\n## Quick Start (0.2.0+)\n\n### With `node-postgres` (owned pool; recommended for CNPG / local PG)\n\n```typescript\nimport { createNodePgStorageAdapter } from '@tummycrypt/tinyland-auth-pg';\n\nconst adapter = createNodePgStorageAdapter({\n  connectionString: process.env.DATABASE_URL!,\n  poolConfig: { max: 10 },\n});\n\nconst user = await adapter.getUser('\u003ctenant-uuid\u003e', '\u003cuser-id\u003e');\n```\n\n### With `postgres.js` (recommended for PgBouncer transaction mode)\n\n```typescript\nimport postgres from 'postgres';\nimport { drizzle } from 'drizzle-orm/postgres-js';\nimport { createPgStorageAdapter } from '@tummycrypt/tinyland-auth-pg';\nimport * as schema from '@tummycrypt/tinyland-auth-pg/schema';\n\n// prepare: false is required when talking to PgBouncer in transaction mode\nconst sql = postgres(process.env.DATABASE_URL!, { prepare: false, max: 10 });\nconst db = drizzle(sql, { schema });\n\nconst storage = createPgStorageAdapter({ db });\n\n// Every method takes tenantId first\nconst user = await storage.getUser('\u003ctenant-uuid\u003e', '\u003cuser-id\u003e');\n```\n\n### With Neon HTTP (legacy, still supported)\n\n```typescript\nimport { createPgStorageAdapter } from '@tummycrypt/tinyland-auth-pg';\n\nconst storage = createPgStorageAdapter({\n  connectionString: process.env.DATABASE_URL!,\n  sessionMaxAge: 7 * 24 * 60 * 60 * 1000, // 7 days (default)\n});\n\nconst user = await storage.getUser('\u003ctenant-uuid\u003e', '\u003cuser-id\u003e');\n```\n\n### Bootstrap deploy users\n\nUse `bootstrapUsers()` when an app needs to seed tenant-scoped admin users\nduring deploy/startup without duplicating raw SQL.\n\n```typescript\nimport { bootstrapUsers } from '@tummycrypt/tinyland-auth-pg';\nimport { hashPassword } from '@tummycrypt/tinyland-auth';\n\nawait bootstrapUsers({\n  pool, // existing pg.Pool, owned by the caller\n  tenantId,\n  users: [\n    {\n      handle: 'jess',\n      email: 'jess@example.com',\n      displayName: 'Jess Sullivan',\n      pin: '123456',\n      role: 'admin',\n    },\n  ],\n});\n```\n\nThe helper accepts an existing tenant-scoped `storage` adapter, an existing\n`pg.Pool`, or a `connectionString`. Existing users are updated by default so\npassword, role, email, and display name changes converge on rerun. Pass\n`updateExisting: false` to leave existing users untouched.\n\n### Row-Level Security recommended pattern\n\nPair the adapter with a `withTenant` wrapper at the app-layer so every query\nalso flows through an RLS `SET LOCAL`. The explicit `tenantId` param is your\nfirst line of defense; the `SET LOCAL` is belt-and-suspenders if a call-site\never forgets to scope.\n\n```typescript\nawait sql.begin(async (tx) =\u003e {\n  await tx`SELECT set_config('app.tenant_id', ${tenantId}, true)`;\n  return storage.getUser(tenantId, userId);\n});\n```\n\n## Schema Overview\n\nThe package exports six Drizzle schema modules, each targeting a specific domain:\n\n| Export | Schema | Tables | Purpose |\n|--------|--------|--------|---------|\n| `./schema` | `auth` | users, sessions, totp_secrets, backup_codes, invitations, audit_events | Authentication and authorization |\n| `./content-schema` | `public` | business_profile, services, business_hours, reviews, practitioners | CMS content |\n| `./booking-schema` | `public` | clients, bookings, time_blocks, business_hours_overrides, slot_reservations | Scheduling and appointments |\n| `./giftcert-schema` | `public` | gift_certificates, gift_certificate_redemptions | Gift certificate tracking |\n| `./intake-schema` | `public` | intake_submissions | Patient intake forms |\n| `./business-schema` | `public` | (composite re-export) | Business domain aggregation |\n\n### Auth Schema (`auth.*`)\n\n- **users** -- Admin users with roles (viewer, editor, business_owner, developer), PIN hashes, TOTP state, onboarding tracking\n- **sessions** -- DB-backed sessions with HMAC-signed UUIDs, metadata (IP, user agent), configurable TTL\n- **totp_secrets** -- AES-encrypted TOTP secrets, linked to users\n- **backup_codes** -- Bcrypt-hashed one-time recovery codes\n- **invitations** -- Email-based user invitations with token + expiry\n- **audit_events** -- Timestamped auth event log (login, logout, failed attempts, role changes)\n\n### Booking Schema (`public.*`)\n\n- **clients** -- Client directory (name, email, phone, notes)\n- **bookings** -- Appointment records with status (confirmed, cancelled, completed, no_show), payment tracking\n- **time_blocks** -- Practitioner availability blocks (break, vacation, hold)\n- **business_hours_overrides** -- Date-specific hour overrides\n- **slot_reservations** -- Temporary slot holds during booking flow (TTL-based)\n\n## Drizzle Migrations\n\nPush schema changes directly (development):\n\n```bash\n# Auth schema\nDATABASE_URL=\"postgresql://...\" pnpm db:push\n\n# Public schema (booking, content)\nDATABASE_URL=\"postgresql://...\" npx drizzle-kit push --config=drizzle.public.config.ts\n```\n\nGenerate migration files (production):\n\n```bash\nDATABASE_URL=\"postgresql://...\" pnpm db:generate\nDATABASE_URL=\"postgresql://...\" pnpm db:migrate\n```\n\n## API Reference\n\n### `createPgStorageAdapter(config: PgStorageConfig): PgStorageAdapter`\n\nFactory function that returns a Pattern B tenant-scoped adapter.\n\n```typescript\ntype PgStorageConfig =\n  | { db: Database; sessionMaxAge?: number }       // driver injection (recommended)\n  | { connectionString: string; sessionMaxAge?: number }; // legacy neon-http\n\ntype Database =\n  | NeonHttpDatabase\u003ctypeof schema\u003e\n  | NodePgDatabase\u003ctypeof schema\u003e\n  | PostgresJsDatabase\u003ctypeof schema\u003e;\n```\n\nBoth branches validate their input at construction time and throw loudly on\nnullish `db` or empty `connectionString` rather than deferring to the first\nquery.\n\n### `createNodePgStorageAdapter(config: NodePgStorageConfig): NodePgStorageAdapter`\n\nFactory function that constructs and owns a `pg.Pool` for standard PostgreSQL.\n\n```typescript\ninterface NodePgStorageConfig {\n  connectionString: string;\n  sessionMaxAge?: number;\n  poolConfig?: PoolConfig;\n  closeOnDispose?: boolean; // default true\n}\n```\n\n### `bootstrapUsers(config: BootstrapUsersConfig): Promise\u003cBootstrapUsersResult\u003e`\n\nIdempotently seeds or updates tenant-scoped auth users through the adapter\nboundary. No raw SQL is required at the consumer.\n\n```typescript\ntype BootstrapUsersConfig = {\n  tenantId: string;\n  users: Array\u003c{\n    handle: string;\n    email: string;\n    displayName?: string;\n    pin?: string; // or the password field, or a precomputed hash\n    role: AdminRole;\n  }\u003e;\n  updateExisting?: boolean; // default true\n} \u0026 (\n  | { storage: BootstrapUserStorage }\n  | { pool: Pool }\n  | { connectionString: string; poolConfig?: PoolConfig }\n);\n```\n\nEach user must provide one credential source: `pin`, a plaintext password, or\na precomputed password hash. A custom `passwordHasher` may be supplied; when it\nis omitted, the helper uses `@tummycrypt/tinyland-auth`'s `hashPassword`.\n\n### `PgStorageAdapter`\n\nEvery method accepts `tenantId: string` as its **first parameter** and returns\n`TenantScoped\u003cT\u003e` where the domain type carries `tenantId`. Key methods:\n\n#### User Management\n- `getUser(tenantId, id): Promise\u003cTenantScoped\u003cAdminUser\u003e | null\u003e`\n- `getUserByHandle(tenantId, handle): Promise\u003cTenantScoped\u003cAdminUser\u003e | null\u003e`\n- `getUserByEmail(tenantId, email): Promise\u003cTenantScoped\u003cAdminUser\u003e | null\u003e`\n- `createUser(tenantId, user): Promise\u003cTenantScoped\u003cAdminUser\u003e\u003e`\n- `updateUser(tenantId, id, updates): Promise\u003cTenantScoped\u003cAdminUser\u003e\u003e`\n- `deleteUser(tenantId, id): Promise\u003cvoid\u003e`\n- `getAllUsers(tenantId): Promise\u003cTenantScoped\u003cAdminUser\u003e[]\u003e`\n- `hasUsers(tenantId): Promise\u003cboolean\u003e`\n\n#### Session Management\n- `createSession(tenantId, userId, metadata?): Promise\u003cTenantScoped\u003cSession\u003e\u003e`\n- `getSession(tenantId, sessionId): Promise\u003cTenantScoped\u003cSession\u003e | null\u003e`\n- `updateSession(tenantId, sessionId, updates): Promise\u003cTenantScoped\u003cSession\u003e\u003e`\n- `deleteSession(tenantId, sessionId): Promise\u003cvoid\u003e`\n- `deleteUserSessions(tenantId, userId): Promise\u003cvoid\u003e`\n- `getSessionsByUser(tenantId, userId): Promise\u003cTenantScoped\u003cSession\u003e[]\u003e`\n- `getAllSessions(tenantId): Promise\u003cTenantScoped\u003cSession\u003e[]\u003e`\n- `cleanupExpiredSessions(tenantId): Promise\u003cnumber\u003e`\n\n#### TOTP / Backup Codes\n- `saveTOTPSecret(tenantId, handle, secret): Promise\u003cvoid\u003e`\n- `getTOTPSecret(tenantId, handle): Promise\u003cEncryptedTOTPSecret | null\u003e`\n- `deleteTOTPSecret(tenantId, handle): Promise\u003cvoid\u003e`\n- `saveBackupCodes(tenantId, userId, codes): Promise\u003cvoid\u003e`\n- `getBackupCodes(tenantId, userId): Promise\u003cBackupCodeSet | null\u003e`\n- `deleteBackupCodes(tenantId, userId): Promise\u003cvoid\u003e`\n\n#### Invitations\n- `createInvitation(tenantId, invitation): Promise\u003cTenantScoped\u003cInvitation\u003e\u003e`\n- `getInvitation(tenantId, token): Promise\u003cTenantScoped\u003cInvitation\u003e | null\u003e`\n- `getInvitationById(tenantId, id): Promise\u003cTenantScoped\u003cInvitation\u003e | null\u003e`\n- `getAllInvitations(tenantId): Promise\u003cTenantScoped\u003cInvitation\u003e[]\u003e`\n- `getPendingInvitations(tenantId): Promise\u003cTenantScoped\u003cInvitation\u003e[]\u003e`\n- `updateInvitation(tenantId, token, updates): Promise\u003cTenantScoped\u003cInvitation\u003e\u003e`\n- `deleteInvitation(tenantId, token): Promise\u003cvoid\u003e`\n- `cleanupExpiredInvitations(tenantId): Promise\u003cnumber\u003e`\n\n#### Audit Log\n- `logAuditEvent(tenantId, event): Promise\u003cvoid\u003e`\n- `getAuditEvents(tenantId, filters?): Promise\u003cAuditEvent[]\u003e`\n\n\u003e **Interface note:** the class does not `implements IStorageAdapter` from\n\u003e `@tummycrypt/tinyland-auth@0.2.x` because the peer package predates Pattern B.\n\u003e An interface uplift will ship with tinyland-auth 0.3.0 and this adapter will\n\u003e re-implement it then. Until then, consume the concrete class or type against\n\u003e the exported method signatures directly.\n\n### `NodePgStorageAdapter`\n\nSubclass of `PgStorageAdapter` that exposes its owned `pool: Pool` and closes\nthat pool by default when `adapter.close()` is called.\n\n## Environment Variables\n\n| Variable | Required | Description |\n|----------|----------|-------------|\n| `DATABASE_URL` | Yes | PostgreSQL connection string for Neon, CNPG, local PG, or other supported deployments |\n\n## Development\n\n```bash\npnpm install\npnpm test          # Run tests\npnpm build         # Compile TypeScript\npnpm test:watch    # Watch mode\n```\n\n### Nix\n\n```bash\nnix develop        # Enter dev shell with Node 20 + pnpm + tsc\n```\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftinyland-inc%2Ftinyland-auth-pg","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftinyland-inc%2Ftinyland-auth-pg","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftinyland-inc%2Ftinyland-auth-pg/lists"}