Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,5 @@ npm_package(
"package.json",
],
package = "@tummycrypt/tinyland-auth-pg",
version = "0.2.3",
version = "0.2.4",
)
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ All notable changes to `@tummycrypt/tinyland-auth-pg` will be documented here.
Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
this package uses pre-1.0 semver where **breaking changes bump the minor**.

## [0.2.4] — 2026-04-28

### Added

- `bootstrapUsers({ storage | pool | connectionString, tenantId, users, passwordHasher? })`
as an idempotent tenant-scoped bootstrap helper for apps that seed admin
users during deploy/startup.
- `./bootstrap-users` package export and root export for the helper and its
public types.

### Fixed

- Keep the bootstrap flow on the shared adapter boundary instead of requiring
consumers to duplicate raw SQL upsert logic.

## [0.2.3] — 2026-04-28

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion MODULE.bazel
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module(
name = "tummycrypt_tinyland_auth_pg",
version = "0.2.3",
version = "0.2.4",
compatibility_level = 1,
)

Expand Down
4 changes: 2 additions & 2 deletions MODULE.bazel.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,35 @@ const storage = createPgStorageAdapter({
const user = await storage.getUser('<tenant-uuid>', '<user-id>');
```

### 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
Expand Down Expand Up @@ -167,6 +196,33 @@ interface NodePgStorageConfig {
}
```

### `bootstrapUsers(config: BootstrapUsersConfig): Promise<BootstrapUsersResult>`

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
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tummycrypt/tinyland-auth-pg",
"version": "0.2.3",
"version": "0.2.4",
"type": "module",
"packageManager": "pnpm@10.13.1",
"license": "MIT",
Expand All @@ -21,6 +21,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./bootstrap-users": {
"types": "./dist/bootstrap-users.d.ts",
"import": "./dist/bootstrap-users.js"
},
"./schema": {
"types": "./dist/schema.d.ts",
"import": "./dist/schema.js"
Expand Down
240 changes: 240 additions & 0 deletions src/__tests__/bootstrap-users.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import { describe, it, expect } from 'vitest';
import type { AdminUser } from '@tummycrypt/tinyland-auth/types';
import {
bootstrapUsers,
type BootstrapUserStorage,
type TenantScoped,
} from '../index.js';

const TENANT_ID = '11111111-1111-1111-1111-111111111111';

class FakeBootstrapStorage implements BootstrapUserStorage {
private readonly users = new Map<string, TenantScoped<AdminUser>>();
createCalls = 0;
updateCalls = 0;

constructor(seed: TenantScoped<AdminUser>[] = []) {
for (const user of seed) {
this.users.set(user.handle, user);
}
}

async getUserByHandle(
tenantId: string,
handle: string,
): Promise<TenantScoped<AdminUser> | null> {
return this.users.get(handle.toLowerCase()) ?? null;
}

async createUser(
tenantId: string,
user: Omit<AdminUser, 'id' | 'tenantId'>,
): Promise<TenantScoped<AdminUser>> {
this.createCalls += 1;
const created: TenantScoped<AdminUser> = {
...user,
id: `user_${this.createCalls}`,
tenantId,
};
this.users.set(created.handle, created);
return created;
}

async updateUser(
tenantId: string,
id: string,
updates: Partial<AdminUser>,
): Promise<TenantScoped<AdminUser>> {
this.updateCalls += 1;
const existing = [...this.users.values()].find((user) => user.id === id);
if (!existing) {
throw new Error(`User ${id} not found`);
}

const updated: TenantScoped<AdminUser> = {
...existing,
...updates,
tenantId,
};
this.users.set(updated.handle, updated);
return updated;
}

allUsers(): TenantScoped<AdminUser>[] {
return [...this.users.values()];
}
}

const user = (
overrides: Partial<TenantScoped<AdminUser>> = {},
): TenantScoped<AdminUser> => ({
id: 'existing',
tenantId: TENANT_ID,
handle: 'jess',
email: 'jess@example.com',
displayName: 'Jess',
passwordHash: 'old_hash',
role: 'viewer',
isActive: true,
needsOnboarding: false,
onboardingStep: 0,
totpEnabled: false,
createdAt: '2026-04-28T00:00:00.000Z',
updatedAt: '2026-04-28T00:00:00.000Z',
...overrides,
});

describe('bootstrapUsers', () => {
it('creates normalized users on cold start', async () => {
const storage = new FakeBootstrapStorage();

const result = await bootstrapUsers({
storage,
tenantId: TENANT_ID,
passwordHasher: async (password) => `hash:${password}`,
users: [
{
handle: ' Jess ',
email: ' Jess@Example.COM ',
displayName: ' Jess Sullivan ',
pin: '123456',
role: 'admin',
},
],
});

expect(result.created).toBe(1);
expect(result.updated).toBe(0);
expect(storage.createCalls).toBe(1);
expect(storage.allUsers()[0]).toMatchObject({
tenantId: TENANT_ID,
handle: 'jess',
email: 'jess@example.com',
displayName: 'Jess Sullivan',
passwordHash: 'hash:123456',
role: 'admin',
isActive: true,
needsOnboarding: false,
onboardingStep: 0,
totpEnabled: false,
});
});

it('reruns idempotently without creating duplicates', async () => {
const storage = new FakeBootstrapStorage();
const config = {
storage,
tenantId: TENANT_ID,
passwordHasher: async (password: string) => `hash:${password}`,
users: [
{
handle: 'jess',
email: 'jess@example.com',
displayName: 'Jess',
pin: '123456',
role: 'admin' as const,
},
],
};

await bootstrapUsers(config);
const result = await bootstrapUsers(config);

expect(result.created).toBe(0);
expect(result.updated).toBe(1);
expect(storage.createCalls).toBe(1);
expect(storage.allUsers()).toHaveLength(1);
});

it('updates bootstrap-owned fields without resetting live auth state', async () => {
const storage = new FakeBootstrapStorage([
user({
isActive: false,
needsOnboarding: true,
onboardingStep: 2,
totpEnabled: true,
permissions: ['posts:edit'],
}),
]);

const result = await bootstrapUsers({
storage,
tenantId: TENANT_ID,
passwordHasher: async (password) => `new:${password}`,
users: [
{
handle: 'jess',
email: 'jess@example.com',
displayName: 'Jess Sullivan',
password: 'new-pin',
role: 'admin',
},
],
});

expect(result.updated).toBe(1);
expect(storage.updateCalls).toBe(1);
expect(storage.allUsers()[0]).toMatchObject({
passwordHash: 'new:new-pin',
role: 'admin',
displayName: 'Jess Sullivan',
isActive: false,
needsOnboarding: true,
onboardingStep: 2,
totpEnabled: true,
permissions: ['posts:edit'],
});
});

it('can leave existing users unchanged when requested', async () => {
const storage = new FakeBootstrapStorage([user()]);
let hashCalls = 0;

const result = await bootstrapUsers({
storage,
tenantId: TENANT_ID,
updateExisting: false,
passwordHasher: async (password) => {
hashCalls += 1;
return `new:${password}`;
},
users: [
{
handle: 'jess',
email: 'jess@example.com',
displayName: 'Jess Sullivan',
password: 'new-pin',
role: 'admin',
},
],
});

expect(result.unchanged).toBe(1);
expect(storage.updateCalls).toBe(0);
expect(hashCalls).toBe(0);
expect(storage.allUsers()[0]).toMatchObject({
passwordHash: 'old_hash',
role: 'viewer',
});
});

it('requires a password source for every user', async () => {
const storage = new FakeBootstrapStorage();

await expect(
bootstrapUsers({
storage,
tenantId: TENANT_ID,
users: [
{
handle: 'jess',
email: 'jess@example.com',
role: 'admin',
},
],
}),
).rejects.toThrow(
'Bootstrap user jess must provide passwordHash, password, or pin',
);
});
});
Loading
Loading