From 8643d8287167d2b0cda21d39bcb30810cf2addd0 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 8 Sep 2026 20:17:02 +0000 Subject: [PATCH] The Postgres adapter, actually implemented Every method threw `not implemented`. It was listed as an available adapter in the exports and the docs, so choosing it got you a system that failed on first use. Seven methods, matching the memory adapter's behaviour exactly, because the two are meant to be interchangeable: Postgres hands back lower-case columns and Date objects, and everything above the adapter is written against camelCase and ISO strings. Three decisions worth stating: - A profile is merged, not replaced (`profile || $n::jsonb`), which is what the memory adapter does. Updating one field of a profile should not drop the others. - Uniqueness on `lower(email)`, because addresses are matched case-insensitively everywhere else, and without it two accounts can share one address. - Table names are validated rather than escaped. Identifiers cannot be bound as parameters, so anything that is not a plain name is refused. `pg` is an optional peer and can be skipped entirely by passing a pool in. `initialize()` creates both tables and is safe to call on every boot, which is cheaper than asking every project to carry a migration for two tables. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017Df2FNu5DhinMV2soRz3cy --- package.json | 10 +- src/adapters/postgres.js | 291 ++++++++++++++++++++++++--------- test/adapters/postgres.test.js | 192 ++++++++++++++++++++++ 3 files changed, 417 insertions(+), 76 deletions(-) create mode 100644 test/adapters/postgres.test.js diff --git a/package.json b/package.json index 3b30fc1..79b1391 100644 --- a/package.json +++ b/package.json @@ -58,5 +58,13 @@ "bugs": { "url": "https://github.com/profullstack/auth-system/issues" }, - "homepage": "https://github.com/profullstack/auth-system#readme" + "homepage": "https://github.com/profullstack/auth-system#readme", + "peerDependencies": { + "pg": "^8" + }, + "peerDependenciesMeta": { + "pg": { + "optional": true + } + } } diff --git a/src/adapters/postgres.js b/src/adapters/postgres.js index f7c3ace..5c04f08 100644 --- a/src/adapters/postgres.js +++ b/src/adapters/postgres.js @@ -1,112 +1,253 @@ /** * PostgreSQL Adapter for Auth System - * - * This adapter uses PostgreSQL for user storage and authentication. - * It requires the pg package. + * + * Users and invalidated tokens in Postgres, with the same behaviour as the + * memory adapter so the two are interchangeable. + * + * The `pg` package is a peer, not a dependency: this module is used by projects + * that never touch Postgres, and a driver they cannot use is a driver they + * should not install. Pass a pool in, or let the adapter make one. + * + * import pg from 'pg'; + * const adapter = new PostgresAdapter({ + * pool: new pg.Pool({ connectionString: process.env.DATABASE_URL }), + * }); + * await adapter.initialize(); // creates the tables if they are missing */ -// import { Pool } from 'pg'; +import { v4 as uuidv4 } from 'uuid'; /** - * PostgreSQL Adapter + * A row as the rest of the auth system expects it. + * + * Postgres hands back lower-case column names and Date objects; the memory + * adapter deals in camelCase and ISO strings, and everything above the adapter + * is written against that shape. */ +function toUser(row) { + if (!row) { + return null; + } + const iso = (value) => (value instanceof Date ? value.toISOString() : value ?? null); + return { + id: row.id, + email: row.email, + password: row.password, + profile: row.profile ?? {}, + emailVerified: row.email_verified ?? false, + createdAt: iso(row.created_at), + updatedAt: iso(row.updated_at), + lastLoginAt: iso(row.last_login_at), + }; +} + +/** Columns a caller may set, and the column each one is stored in. */ +const UPDATABLE = { + email: 'email', + password: 'password', + emailVerified: 'email_verified', + lastLoginAt: 'last_login_at', +}; + export class PostgresAdapter { /** - * Create a new PostgreSQL Adapter - * @param {Object} options - Configuration options - * @param {string} options.host - PostgreSQL host - * @param {number} options.port - PostgreSQL port - * @param {string} options.database - PostgreSQL database name - * @param {string} options.user - PostgreSQL username - * @param {string} options.password - PostgreSQL password - * @param {string} options.usersTable - Name of the users table (default: 'users') - * @param {string} options.tokensTable - Name of the invalidated tokens table (default: 'invalidated_tokens') + * @param {Object} options + * @param {Object} [options.pool] - A pg Pool. Made from the rest if absent. + * @param {string} [options.connectionString] + * @param {string} [options.host] + * @param {number} [options.port] + * @param {string} [options.database] + * @param {string} [options.user] + * @param {string} [options.password] + * @param {string} [options.usersTable] - Default 'users'. + * @param {string} [options.tokensTable] - Default 'invalidated_tokens'. */ - constructor(options) { - // This is a stub implementation - // TODO: Implement PostgreSQL adapter + constructor(options = {}) { this.options = options; - this.usersTable = options.usersTable || 'users'; - this.tokensTable = options.tokensTable || 'invalidated_tokens'; + // Identifiers cannot be parameterised, so they are restricted rather than + // quoted: a table name is configuration, but it still reaches SQL as text. + this.usersTable = safeIdentifier(options.usersTable || 'users'); + this.tokensTable = safeIdentifier(options.tokensTable || 'invalidated_tokens'); + this.pool = options.pool ?? null; + this.ready = false; + } + + /** The pool, made on first use when one was not supplied. */ + async getPool() { + if (this.pool) { + return this.pool; + } + let pg; + try { + pg = (await import('pg')).default; + } catch { + throw new Error( + 'PostgresAdapter needs the `pg` package, or a `pool` passed to its constructor.' + ); + } + const { connectionString, host, port, database, user, password } = this.options; + this.pool = new pg.Pool( + connectionString ? { connectionString } : { host, port, database, user, password } + ); + return this.pool; + } + + async query(text, values = []) { + const pool = await this.getPool(); + return pool.query(text, values); } /** - * Create a new user - * @param {Object} userData - User data - * @returns {Promise} - Created user + * Create the tables if they are not there. Safe to call on every boot, and + * cheaper than asking every project to carry a migration for two tables. */ + async initialize() { + if (this.ready) { + return; + } + await this.query(` + CREATE TABLE IF NOT EXISTS ${this.usersTable} ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + password TEXT, + profile JSONB NOT NULL DEFAULT '{}'::jsonb, + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ + ) + `); + // Addresses are matched case-insensitively everywhere else, so uniqueness + // has to be case-insensitive too, or two accounts can share an address. + await this.query( + `CREATE UNIQUE INDEX IF NOT EXISTS ${this.usersTable}_email_lower_idx + ON ${this.usersTable} (lower(email))` + ); + await this.query(` + CREATE TABLE IF NOT EXISTS ${this.tokensTable} ( + token TEXT PRIMARY KEY, + invalidated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + this.ready = true; + } + async createUser(userData) { - // This is a stub implementation - // TODO: Implement user creation in PostgreSQL - throw new Error('PostgresAdapter.createUser not implemented'); + await this.initialize(); + const id = userData.id || uuidv4(); + const now = new Date().toISOString(); + + const { rows } = await this.query( + `INSERT INTO ${this.usersTable} + (id, email, password, profile, email_verified, created_at, updated_at, last_login_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NULL) + RETURNING *`, + [ + id, + userData.email, + userData.password ?? null, + JSON.stringify(userData.profile || {}), + userData.emailVerified || false, + userData.createdAt || now, + userData.updatedAt || now, + ] + ); + return toUser(rows[0]); } - /** - * Get a user by ID - * @param {string} userId - User ID - * @returns {Promise} - User object or null if not found - */ async getUserById(userId) { - // This is a stub implementation - // TODO: Implement getting user by ID from PostgreSQL - throw new Error('PostgresAdapter.getUserById not implemented'); + await this.initialize(); + const { rows } = await this.query(`SELECT * FROM ${this.usersTable} WHERE id = $1`, [userId]); + return toUser(rows[0]); } - /** - * Get a user by email - * @param {string} email - User email - * @returns {Promise} - User object or null if not found - */ async getUserByEmail(email) { - // This is a stub implementation - // TODO: Implement getting user by email from PostgreSQL - throw new Error('PostgresAdapter.getUserByEmail not implemented'); + await this.initialize(); + const { rows } = await this.query( + `SELECT * FROM ${this.usersTable} WHERE lower(email) = lower($1)`, + [email] + ); + return toUser(rows[0]); } - /** - * Update a user - * @param {string} userId - User ID - * @param {Object} updates - Updates to apply - * @returns {Promise} - Updated user - */ async updateUser(userId, updates) { - // This is a stub implementation - // TODO: Implement updating user in PostgreSQL - throw new Error('PostgresAdapter.updateUser not implemented'); + await this.initialize(); + + const sets = []; + const values = []; + for (const [key, column] of Object.entries(UPDATABLE)) { + if (updates[key] !== undefined) { + values.push(updates[key]); + sets.push(`${column} = $${values.length}`); + } + } + // A profile is merged rather than replaced, matching the memory adapter: + // updating one field of it must not drop the others. + if (updates.profile !== undefined) { + values.push(JSON.stringify(updates.profile)); + sets.push(`profile = profile || $${values.length}::jsonb`); + } + values.push(new Date().toISOString()); + sets.push(`updated_at = $${values.length}`); + + values.push(userId); + const { rows } = await this.query( + `UPDATE ${this.usersTable} SET ${sets.join(', ')} WHERE id = $${values.length} RETURNING *`, + values + ); + if (!rows[0]) { + throw new Error('User not found'); + } + return toUser(rows[0]); } - /** - * Delete a user - * @param {string} userId - User ID - * @returns {Promise} - Whether the user was deleted - */ async deleteUser(userId) { - // This is a stub implementation - // TODO: Implement deleting user from PostgreSQL - throw new Error('PostgresAdapter.deleteUser not implemented'); + await this.initialize(); + const { rowCount } = await this.query(`DELETE FROM ${this.usersTable} WHERE id = $1`, [userId]); + return rowCount > 0; } - /** - * Invalidate a token - * @param {string} token - Token to invalidate - * @returns {Promise} - */ async invalidateToken(token) { - // This is a stub implementation - // TODO: Implement token invalidation in PostgreSQL - throw new Error('PostgresAdapter.invalidateToken not implemented'); + await this.initialize(); + // Invalidating twice is not an error: a client that retries a logout has + // done nothing wrong. + await this.query( + `INSERT INTO ${this.tokensTable} (token) VALUES ($1) ON CONFLICT (token) DO NOTHING`, + [token] + ); } - /** - * Check if a token is invalidated - * @param {string} token - Token to check - * @returns {Promise} - Whether the token is invalidated - */ async isTokenInvalidated(token) { - // This is a stub implementation - // TODO: Implement token invalidation check in PostgreSQL - throw new Error('PostgresAdapter.isTokenInvalidated not implemented'); + await this.initialize(); + const { rows } = await this.query(`SELECT 1 FROM ${this.tokensTable} WHERE token = $1`, [token]); + return rows.length > 0; + } + + /** Empty both tables. For tests. */ + async clear() { + await this.initialize(); + await this.query(`DELETE FROM ${this.usersTable}`); + await this.query(`DELETE FROM ${this.tokensTable}`); + } + + /** Let go of the pool, so a process can exit. */ + async close() { + await this.pool?.end?.(); + this.pool = null; + this.ready = false; + } +} + +/** + * A table name that can be interpolated. Identifiers cannot be bound as + * parameters, so anything that is not a plain name is refused rather than + * escaped and hoped over. + */ +function safeIdentifier(name) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + throw new Error(`PostgresAdapter: unsafe table name ${JSON.stringify(name)}`); } + return name; } -export default PostgresAdapter; \ No newline at end of file +export default PostgresAdapter; diff --git a/test/adapters/postgres.test.js b/test/adapters/postgres.test.js new file mode 100644 index 0000000..ed14a79 --- /dev/null +++ b/test/adapters/postgres.test.js @@ -0,0 +1,192 @@ +/** + * The Postgres adapter, against a fake pool. + * + * A real database would be a better test and a worse one to run: this asserts + * the SQL and the shape it returns, which is where the adapter can actually be + * wrong. The behaviour it has to match is the memory adapter's. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { PostgresAdapter } from '../../src/adapters/postgres.js'; + +/** A pool that records what it was asked and answers what it was told to. */ +function fakePool(answers = []) { + const calls = []; + let at = 0; + return { + calls, + async query(text, values) { + calls.push({ text: text.replace(/\s+/g, ' ').trim(), values }); + const answer = answers[at++]; + return answer ?? { rows: [], rowCount: 0 }; + }, + async end() {}, + }; +} + +const row = (over = {}) => ({ + id: 'u1', + email: 'Someone@Example.com', + password: 'hashed', + profile: { name: 'Someone' }, + email_verified: false, + created_at: new Date('2026-01-01T00:00:00Z'), + updated_at: new Date('2026-01-02T00:00:00Z'), + last_login_at: null, + ...over, +}); + +describe('PostgresAdapter', () => { + let pool; + let adapter; + + beforeEach(() => { + pool = fakePool(); + adapter = new PostgresAdapter({ pool }); + }); + + it('creates its tables once, however many calls are made', async () => { + await adapter.initialize(); + const first = pool.calls.length; + expect(first).toBeGreaterThan(0); + await adapter.initialize(); + expect(pool.calls.length).toBe(first); + + const ddl = pool.calls.map((c) => c.text).join(' '); + expect(ddl).toContain('CREATE TABLE IF NOT EXISTS users'); + expect(ddl).toContain('CREATE TABLE IF NOT EXISTS invalidated_tokens'); + // Addresses are matched case-insensitively, so uniqueness must be too, or + // two accounts can share one address. + expect(ddl).toContain('lower(email)'); + }); + + it('refuses a table name that cannot be interpolated safely', () => { + expect(() => new PostgresAdapter({ pool, usersTable: 'users; DROP TABLE x' })).toThrow( + /unsafe table name/ + ); + expect(() => new PostgresAdapter({ pool, usersTable: 'my_users' })).not.toThrow(); + }); + + it('returns a user in the shape everything above it expects', async () => { + pool = fakePool([{}, {}, {}, { rows: [row()] }]); + adapter = new PostgresAdapter({ pool }); + + const user = await adapter.getUserById('u1'); + expect(user).toEqual({ + id: 'u1', + email: 'Someone@Example.com', + password: 'hashed', + profile: { name: 'Someone' }, + emailVerified: false, + // Dates come back as Date objects and have to leave as ISO strings, the + // way the memory adapter's do. + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + lastLoginAt: null, + }); + }); + + it('answers null for a user who is not there', async () => { + expect(await adapter.getUserById('nobody')).toBe(null); + expect(await adapter.getUserByEmail('nobody@example.com')).toBe(null); + }); + + it('matches an address whatever its case', async () => { + await adapter.getUserByEmail('SOMEONE@example.com'); + const select = pool.calls.at(-1); + expect(select.text).toContain('lower(email) = lower($1)'); + expect(select.values).toEqual(['SOMEONE@example.com']); + }); + + it('creates a user with an id when none was given', async () => { + pool = fakePool([{}, {}, {}, { rows: [row()] }]); + adapter = new PostgresAdapter({ pool }); + + await adapter.createUser({ email: 'someone@example.com', password: 'hashed' }); + const insert = pool.calls.at(-1); + expect(insert.text).toContain('INSERT INTO users'); + expect(insert.values[0]).toMatch(/^[0-9a-f-]{36}$/); + expect(insert.values[1]).toBe('someone@example.com'); + // A profile is stored as JSON, not as [object Object]. + expect(insert.values[3]).toBe('{}'); + }); + + it('merges a profile rather than replacing it', async () => { + pool = fakePool([{}, {}, {}, { rows: [row()] }]); + adapter = new PostgresAdapter({ pool }); + + await adapter.updateUser('u1', { profile: { name: 'New' } }); + const update = pool.calls.at(-1); + // Updating one field of a profile must not drop the others, which is what + // the memory adapter does. + expect(update.text).toContain('profile = profile || '); + expect(update.text).toContain('::jsonb'); + }); + + it('only updates the columns it was given', async () => { + pool = fakePool([{}, {}, {}, { rows: [row()] }]); + adapter = new PostgresAdapter({ pool }); + + await adapter.updateUser('u1', { emailVerified: true }); + const update = pool.calls.at(-1); + expect(update.text).toContain('email_verified = $1'); + expect(update.text).not.toContain('password ='); + // updated_at is always set, and the id is always last. + expect(update.text).toContain('updated_at = $2'); + expect(update.values.at(-1)).toBe('u1'); + }); + + it('throws when updating somebody who is not there', async () => { + pool = fakePool([{}, {}, {}, { rows: [] }]); + adapter = new PostgresAdapter({ pool }); + await expect(adapter.updateUser('nobody', { emailVerified: true })).rejects.toThrow( + 'User not found' + ); + }); + + it('says whether a delete removed anything', async () => { + pool = fakePool([{}, {}, {}, { rowCount: 1 }]); + adapter = new PostgresAdapter({ pool }); + expect(await adapter.deleteUser('u1')).toBe(true); + + pool = fakePool([{}, {}, {}, { rowCount: 0 }]); + adapter = new PostgresAdapter({ pool }); + expect(await adapter.deleteUser('nobody')).toBe(false); + }); + + it('invalidating a token twice is not an error', async () => { + await adapter.invalidateToken('t1'); + const insert = pool.calls.at(-1); + // A client that retries a logout has done nothing wrong. + expect(insert.text).toContain('ON CONFLICT (token) DO NOTHING'); + }); + + it('reports an invalidated token, and only that', async () => { + pool = fakePool([{}, {}, {}, { rows: [{ '?column?': 1 }] }]); + adapter = new PostgresAdapter({ pool }); + expect(await adapter.isTokenInvalidated('t1')).toBe(true); + + pool = fakePool([{}, {}, {}, { rows: [] }]); + adapter = new PostgresAdapter({ pool }); + expect(await adapter.isTokenInvalidated('t2')).toBe(false); + }); + + it('uses the pool it was given, and never reaches for pg', async () => { + // pg is a peer: projects that never touch Postgres should not install a + // driver they cannot use, and one that hands in a pool needs no driver + // lookup at all. + expect(await adapter.getPool()).toBe(pool); + }); + + it('says what is missing when it cannot make a pool', async () => { + const bare = new PostgresAdapter({ connectionString: 'postgres://nowhere' }); + // Whether pg resolves depends on the host project, so this asserts the + // failure is explained rather than that it happens. + try { + const made = await bare.getPool(); + expect(made).toBeTruthy(); + await bare.close(); + } catch (error) { + expect(error.message).toMatch(/needs the `pg` package/); + } + }); +});