Skip to content

The Postgres adapter, actually implemented - #10

Merged
ralyodio merged 1 commit into
masterfrom
feat/postgres-adapter
Sep 8, 2026
Merged

The Postgres adapter, actually implemented#10
ralyodio merged 1 commit into
masterfrom
feat/postgres-adapter

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

PostgresAdapter was a stub. Every one of its seven methods threw
not implemented, while being exported from index.js and offered as one of
the adapters — so picking it got you an auth system that failed on first use.

This implements it, matching MemoryAdapter's behaviour exactly, since the two
are meant to be interchangeable. Postgres hands back lower-case column names and
Date objects; everything above the adapter is written against camelCase and
ISO strings, so the adapter converts.

Three decisions worth stating

  • A profile is merged, not replaced (profile = profile || $n::jsonb),
    because that is what the memory adapter does. Updating one field of a profile
    should not silently drop the others.
  • Uniqueness on lower(email). Addresses are matched case-insensitively
    everywhere else, so without it Someone@example.com and someone@example.com
    are two accounts.
  • Table names are validated, not escaped. Identifiers cannot be bound as
    parameters, so anything that is not a plain name is refused rather than quoted
    and hoped over.

pg is an optional peer

Projects that never touch Postgres should not install a driver they cannot use.
Pass a pool in and no driver lookup happens at all:

const adapter = new PostgresAdapter({ pool: new pg.Pool({ connectionString }) });
await adapter.initialize();

initialize() creates both tables and is safe on every boot, which is cheaper
than asking every project to carry a migration for two tables.

Verified

  • 14 new tests against a recording fake pool: the SQL, the returned shape, the
    case-insensitive lookup, the profile merge, partial updates, delete counts,
    ON CONFLICT DO NOTHING on repeated invalidation, and the table-name guard
  • 250 tests pass across the whole suite

Found while wiring nixamp.com's login to this module.

🤖 Generated with Claude Code

https://claude.ai/code/session_017Df2FNu5DhinMV2soRz3cy

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Df2FNu5DhinMV2soRz3cy
Comment thread src/adapters/postgres.js
/** Columns a caller may set, and the column each one is stored in. */
const UPDATABLE = {
email: 'email',
password: 'password',
Comment thread src/adapters/postgres.js
const now = new Date().toISOString();

const { rows } = await this.query(
`INSERT INTO ${this.usersTable}
Comment thread src/adapters/postgres.js
// 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]);
Comment thread src/adapters/postgres.js
throw new Error('PostgresAdapter.getUserByEmail not implemented');
await this.initialize();
const { rows } = await this.query(
`SELECT * FROM ${this.usersTable} WHERE lower(email) = lower($1)`,
Comment thread src/adapters/postgres.js

values.push(userId);
const { rows } = await this.query(
`UPDATE ${this.usersTable} SET ${sets.join(', ')} WHERE id = $${values.length} RETURNING *`,
Comment thread src/adapters/postgres.js
// 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]);
Comment thread src/adapters/postgres.js
// 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`,
Comment thread src/adapters/postgres.js
// 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]);
Comment thread src/adapters/postgres.js
/** Empty both tables. For tests. */
async clear() {
await this.initialize();
await this.query(`DELETE FROM ${this.usersTable}`);
Comment thread src/adapters/postgres.js
async clear() {
await this.initialize();
await this.query(`DELETE FROM ${this.usersTable}`);
await this.query(`DELETE FROM ${this.tokensTable}`);
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

91 finding(s)

HIGH/CRITICAL: 10 | MEDIUM: 10 | LOW: 71

Severity Rule Location
HIGH secret-generic-credential examples/pocketbase-usage.js:57
HIGH secret-generic-credential examples/pocketbase-usage.js:77
HIGH secret-generic-credential examples/pocketbase-usage.js:122
HIGH secret-generic-credential examples/pocketbase-usage.js:123
HIGH secret-generic-credential examples/pocketbase-usage.js:149
HIGH secret-generic-credential examples/supabase-usage.js:56
HIGH secret-generic-credential examples/supabase-usage.js:76
HIGH secret-generic-credential examples/supabase-usage.js:121
HIGH secret-generic-credential examples/supabase-usage.js:122
HIGH secret-generic-credential examples/supabase-usage.js:148
MEDIUM sql-template-interpolation src/adapters/postgres.js:141
MEDIUM sql-template-interpolation src/adapters/postgres.js:160
MEDIUM sql-template-interpolation src/adapters/postgres.js:167
MEDIUM sql-template-interpolation src/adapters/postgres.js:195
MEDIUM sql-template-interpolation src/adapters/postgres.js:206
MEDIUM sql-template-interpolation src/adapters/postgres.js:215
MEDIUM sql-template-interpolation src/adapters/postgres.js:222
MEDIUM sql-template-interpolation src/adapters/postgres.js:229
MEDIUM sql-template-interpolation src/adapters/postgres.js:230
MEDIUM js-jwt-decode-without-verify src/utils/token.js:253
LOW secret-generic-credential examples/basic-usage.js:10
LOW secret-generic-credential examples/basic-usage.js:45
LOW secret-generic-credential examples/basic-usage.js:64
LOW secret-generic-credential examples/basic-usage.js:104
LOW secret-generic-credential examples/basic-usage.js:105
LOW secret-generic-credential examples/basic-usage.js:116
LOW secret-generic-credential examples/basic-usage.js:159
LOW secret-generic-credential examples/basic-usage.js:170
LOW js-unescaped-html-sink examples/browser-integration/api-keys-page.js:62
LOW js-unescaped-html-sink examples/browser-integration/api-keys-page.js:85
LOW secret-generic-credential examples/browser-integration/README.md:102
LOW js-open-redirect examples/browser-integration/register-page.js:150
LOW js-unescaped-html-sink examples/browser-integration/settings-page.js:89
LOW secret-generic-credential examples/pocketbase-usage.js:13
LOW secret-generic-credential examples/pocketbase-usage.js:20
LOW secret-generic-credential examples/supabase-usage.js:19
LOW secret-generic-credential README.md:30
LOW secret-generic-credential README.md:39
LOW secret-generic-credential README.md:49
LOW secret-generic-credential README.md:71
LOW secret-generic-credential README.md:108
LOW secret-generic-credential README.md:122
LOW secret-generic-credential README.md:146
LOW secret-generic-credential README.md:147
LOW secret-generic-credential README.md:177
LOW secret-generic-credential README.md:178
LOW secret-generic-credential README.md:248
LOW secret-generic-credential README.md:273
LOW secret-generic-credential README.md:292
LOW secret-generic-credential README.md:300

…and 41 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 32c872f into master Sep 8, 2026
4 of 5 checks passed
@ralyodio
ralyodio deleted the feat/postgres-adapter branch September 8, 2026 20:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants