From b11bfb35cc8db2f2da67d0e5430eb81d12ca5363 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 22 Jun 2026 11:19:01 +0100 Subject: [PATCH 1/4] Refactor OAuth store into a backend selector (Blobs default) Split the OAuth state store into pluggable backends behind store.mjs: - db/blobs.mjs: the current Netlify Blobs implementation (extracted, no behavior change), default backend. - db/neon.mjs: a Neon Postgres backend whose one-time-use consumes are atomic single statements (UPDATE/DELETE ... RETURNING), closing the read-then-delete race Blobs can't (no compare-and-swap). - store.mjs: thin selector by STORE_BACKEND (default blobs); DCR clients stay on Blobs (plain persistence, no atomicity benefit). Replaces the non-atomic markRefreshUsed with consumeRefresh: on Neon only one of two concurrent refreshes wins the row; the loser is treated as reuse and the family is revoked, restoring theft detection under races. Neon driver is imported lazily so the default path needs no DB or dep. No caller behavior changes on the default backend; all 56 tests pass. --- netlify/functions/lib/oauth/db/blobs.mjs | 108 +++++++++++++++ netlify/functions/lib/oauth/db/neon.mjs | 165 +++++++++++++++++++++++ netlify/functions/lib/oauth/store.mjs | 141 +++++-------------- netlify/functions/mcp-oauth.mjs | 13 +- 4 files changed, 320 insertions(+), 107 deletions(-) create mode 100644 netlify/functions/lib/oauth/db/blobs.mjs create mode 100644 netlify/functions/lib/oauth/db/neon.mjs diff --git a/netlify/functions/lib/oauth/db/blobs.mjs b/netlify/functions/lib/oauth/db/blobs.mjs new file mode 100644 index 00000000..a7312cbd --- /dev/null +++ b/netlify/functions/lib/oauth/db/blobs.mjs @@ -0,0 +1,108 @@ +// OAuth state storage — Netlify Blobs backend. +// +// Extracted from store.mjs so the backend can be selected at runtime (see +// store.mjs). This is the default backend. Keyed lookups by id/hash fit Blobs. +// +// KNOWN LIMITATION (the reason the Neon backend exists): one-time-use here is +// read-then-delete / read-then-mark, and Blobs has no compare-and-swap, so two +// *simultaneous* requests with the same auth code (or refresh token) can both +// observe it as unused before either consumes it. For refresh tokens a +// concurrent legit+stolen use inside that window would both rotate without +// tripping family revocation. The Neon backend fixes this with a single atomic +// `UPDATE … WHERE used = false RETURNING`. + +import { getStore } from '@netlify/blobs' +import { randomUUID, randomBytes } from 'node:crypto' +import { AUTH_REQUEST_TTL_SEC, AUTH_CODE_TTL_SEC } from '../config.mjs' + +const STORE = 'mcp-oauth' +const AR = (id) => `ar:${id}` // auth request +const AC = (code) => `ac:${code}` // authorization code +const CL = (id) => `client:${id}` // DCR-registered client +const RT = (h) => `rt:${h}` // refresh token (by hash) +const RTF = (id) => `rtf:${id}` // refresh-token family + +function store() { + // STRONG consistency: auth codes and refresh tokens are one-time use, and + // Blobs' default eventual consistency propagates deletes/updates over up to + // 60s — long enough for a consumed code/token to be replayed in that window. + return getStore({ name: STORE, consistency: 'strong' }) +} +const expired = (rec) => !rec || Date.now() > rec.expiresAt + +export async function putAuthRequest(data) { + const id = randomUUID() + await store().setJSON(AR(id), { ...data, expiresAt: Date.now() + AUTH_REQUEST_TTL_SEC * 1000 }) + return id +} + +export async function takeAuthRequest(id) { + if (!id) return null + const key = AR(id) + const rec = await store().get(key, { type: 'json' }).catch(() => null) + await store().delete(key).catch(() => {}) // one-time + return expired(rec) ? null : rec +} + +export async function putAuthCode(data) { + const code = randomBytes(32).toString('base64url') + await store().setJSON(AC(code), { ...data, used: false, expiresAt: Date.now() + AUTH_CODE_TTL_SEC * 1000 }) + return code +} + +export async function takeAuthCode(code) { + if (!code) return null + const key = AC(code) + const rec = await store().get(key, { type: 'json' }).catch(() => null) + if (expired(rec) || rec.used) return null + await store().delete(key).catch(() => {}) // one-time use + return rec +} + +// --- DCR-registered clients (persistent; no TTL) --- +export async function putClient(client) { + await store().setJSON(CL(client.client_id), client) + return client +} + +export async function getStoredClient(clientId) { + if (!clientId) return null + // Resilient: a store error (e.g. Blobs unavailable) resolves to "unknown + // client" rather than crashing the /authorize handler. + try { + return await store().get(CL(clientId), { type: 'json' }) + } catch { + return null + } +} + +// --- refresh tokens (by hash) + families --- +export async function putRefresh(hash, rec) { + await store().setJSON(RT(hash), rec) +} +export async function getRefresh(hash) { + if (!hash) return null + return store().get(RT(hash), { type: 'json' }).catch(() => null) +} + +// Best-effort "consume": mark the token used and return it, or null if it was +// already used / missing. NOT atomic (no CAS on Blobs) — a concurrent caller +// can also observe it unused. The Neon backend makes this atomic. +export async function consumeRefresh(hash) { + const rec = await getRefresh(hash) + if (!rec || rec.used) return null + await store().setJSON(RT(hash), { ...rec, used: true }) + return rec +} + +export async function putFamily(id, rec) { + await store().setJSON(RTF(id), rec) +} +export async function getFamily(id) { + if (!id) return null + return store().get(RTF(id), { type: 'json' }).catch(() => null) +} +export async function revokeFamily(id) { + const fam = (await getFamily(id)) || {} + await store().setJSON(RTF(id), { ...fam, revoked: true, revokedAt: Date.now() }) +} diff --git a/netlify/functions/lib/oauth/db/neon.mjs b/netlify/functions/lib/oauth/db/neon.mjs new file mode 100644 index 00000000..f02f02c9 --- /dev/null +++ b/netlify/functions/lib/oauth/db/neon.mjs @@ -0,0 +1,165 @@ +// OAuth state storage — Neon Postgres (Netlify DB) backend. +// +// Selected when STORE_BACKEND=neon (see store.mjs). Exists to make one-time-use +// of auth codes and refresh tokens ATOMIC: each consume is a single +// `UPDATE … WHERE used = false RETURNING *` (or `DELETE … RETURNING *`), so two +// concurrent requests with the same code/token cannot both succeed — exactly +// one wins the row, the loser sees zero rows. This closes the replay/reuse race +// that the Blobs backend documents. +// +// Scope: the four one-time-use / transactional tables only. DCR clients stay on +// Blobs (they are plain persistence, not one-time-use — no atomicity benefit). +// +// Driver is imported lazily so this module can be loaded without the dependency +// or a database URL present (e.g. when STORE_BACKEND=blobs in tests/CI). + +import { randomUUID, randomBytes } from 'node:crypto' +import { AUTH_REQUEST_TTL_SEC, AUTH_CODE_TTL_SEC } from '../config.mjs' + +let _sql = null +async function db() { + if (_sql) return _sql + const url = process.env.NETLIFY_DATABASE_URL + if (!url) throw new Error('NETLIFY_DATABASE_URL is not set (STORE_BACKEND=neon requires a provisioned Neon DB)') + const { neon } = await import('@neondatabase/serverless') + _sql = neon(url) + return _sql +} + +const toMs = (ts) => (ts ? new Date(ts).getTime() : 0) + +// --- auth requests (one-time; consumed by delete) --- +export async function putAuthRequest(data) { + const sql = await db() + const id = randomUUID() + const expiresMs = Date.now() + AUTH_REQUEST_TTL_SEC * 1000 + await sql` + INSERT INTO auth_requests (id, client_id, client_redirect_uri, client_state, client_code_challenge, upstream_verifier, expires_at) + VALUES (${id}, ${data.clientId}, ${data.clientRedirectUri}, ${data.clientState ?? null}, ${data.clientCodeChallenge ?? null}, ${data.upstreamVerifier ?? null}, to_timestamp(${expiresMs} / 1000.0)) + ` + return id +} + +export async function takeAuthRequest(id) { + if (!id) return null + const sql = await db() + const rows = await sql`DELETE FROM auth_requests WHERE id = ${id} RETURNING *` + const row = rows[0] + if (!row || toMs(row.expires_at) < Date.now()) return null + return { + clientId: row.client_id, + clientRedirectUri: row.client_redirect_uri, + clientState: row.client_state, + clientCodeChallenge: row.client_code_challenge, + upstreamVerifier: row.upstream_verifier, + expiresAt: toMs(row.expires_at), + } +} + +// --- authorization codes (one-time; atomic check+consume) --- +export async function putAuthCode(data) { + const sql = await db() + const code = randomBytes(32).toString('base64url') + const expiresMs = Date.now() + AUTH_CODE_TTL_SEC * 1000 + await sql` + INSERT INTO auth_codes (code, client_id, client_redirect_uri, client_code_challenge, user_data, used, expires_at) + VALUES (${code}, ${data.clientId}, ${data.clientRedirectUri}, ${data.clientCodeChallenge ?? null}, ${JSON.stringify(data.user)}::jsonb, false, to_timestamp(${expiresMs} / 1000.0)) + ` + return code +} + +export async function takeAuthCode(code) { + if (!code) return null + const sql = await db() + // Atomic: only the first caller flips used=false→true and gets the row. + const rows = await sql` + UPDATE auth_codes SET used = true + WHERE code = ${code} AND used = false AND expires_at > now() + RETURNING * + ` + const row = rows[0] + if (!row) return null + return { + clientId: row.client_id, + clientRedirectUri: row.client_redirect_uri, + clientCodeChallenge: row.client_code_challenge, + user: row.user_data, + expiresAt: toMs(row.expires_at), + } +} + +// --- refresh tokens (by hash) --- +export async function putRefresh(hash, rec) { + const sql = await db() + await sql` + INSERT INTO refresh_tokens (hash, family_id, client_id, user_data, scope, used, expires_at) + VALUES (${hash}, ${rec.familyId}, ${rec.clientId}, ${JSON.stringify(rec.user)}::jsonb, ${rec.scope ?? null}, ${rec.used ?? false}, to_timestamp(${rec.expiresAt} / 1000.0)) + ` +} + +export async function getRefresh(hash) { + if (!hash) return null + const sql = await db() + const rows = await sql`SELECT * FROM refresh_tokens WHERE hash = ${hash}` + const row = rows[0] + if (!row) return null + return { + familyId: row.family_id, + clientId: row.client_id, + user: row.user_data, + scope: row.scope, + used: row.used, + expiresAt: toMs(row.expires_at), + } +} + +// Atomic consume: exactly one concurrent caller flips used=false→true and gets +// the row back. A loser (already used / missing) gets null → caller trips reuse. +export async function consumeRefresh(hash) { + if (!hash) return null + const sql = await db() + const rows = await sql` + UPDATE refresh_tokens SET used = true + WHERE hash = ${hash} AND used = false + RETURNING * + ` + const row = rows[0] + if (!row) return null + return { + familyId: row.family_id, + clientId: row.client_id, + user: row.user_data, + scope: row.scope, + used: true, + expiresAt: toMs(row.expires_at), + } +} + +// --- refresh-token families --- +export async function putFamily(id, rec) { + const sql = await db() + await sql` + INSERT INTO refresh_families (id, client_id, revoked, created_at) + VALUES (${id}, ${rec.clientId ?? null}, ${rec.revoked ?? false}, to_timestamp(${rec.createdAt ?? Date.now()} / 1000.0)) + ON CONFLICT (id) DO NOTHING + ` +} + +export async function getFamily(id) { + if (!id) return null + const sql = await db() + const rows = await sql`SELECT * FROM refresh_families WHERE id = ${id}` + const row = rows[0] + if (!row) return null + return { + clientId: row.client_id, + revoked: row.revoked, + createdAt: toMs(row.created_at), + revokedAt: row.revoked_at ? toMs(row.revoked_at) : null, + } +} + +export async function revokeFamily(id) { + const sql = await db() + await sql`UPDATE refresh_families SET revoked = true, revoked_at = now() WHERE id = ${id}` +} diff --git a/netlify/functions/lib/oauth/store.mjs b/netlify/functions/lib/oauth/store.mjs index cc697628..6cf3d481 100644 --- a/netlify/functions/lib/oauth/store.mjs +++ b/netlify/functions/lib/oauth/store.mjs @@ -1,107 +1,38 @@ -// AS state storage — auth requests (in-flight) + authorization codes. +// OAuth state storage — backend selector. // -// Backed by Netlify Blobs (available today; key-value fits the AS state, which -// is all keyed lookups by id/hash). The interface below is the seam for a -// Netlify DB (Neon Postgres) backend when relational queries/analytics are -// needed — swap the four functions; callers don't change. +// The public interface here is stable; callers (mcp-oauth.mjs, clients.mjs) +// don't know or care which backend is active. The one-time-use / transactional +// state (auth requests, auth codes, refresh tokens, families) is served by +// either the Blobs backend (default) or the Neon Postgres backend, chosen by +// STORE_BACKEND. The Neon backend exists because Blobs has no compare-and-swap, +// so its one-time-use is best-effort; Neon makes consume atomic. // -// NOTE: refresh tokens (Milestone 3) and registered clients/DCR (Milestone 2) -// will add tables/namespaces here. - -import { getStore } from '@netlify/blobs' -import { randomUUID, randomBytes } from 'node:crypto' -import { AUTH_REQUEST_TTL_SEC, AUTH_CODE_TTL_SEC } from './config.mjs' - -const STORE = 'mcp-oauth' -const AR = (id) => `ar:${id}` // auth request -const AC = (code) => `ac:${code}` // authorization code -const CL = (id) => `client:${id}` // DCR-registered client -const RT = (h) => `rt:${h}` // refresh token (by hash) -const RTF = (id) => `rtf:${id}` // refresh-token family - -function store() { - // STRONG consistency is required: auth codes and refresh tokens are one-time - // use, and Blobs' default eventual consistency propagates deletes/updates over - // up to 60s — long enough for a consumed code/token to be replayed in that - // window. Strong reads come from the origin region (fine at our volume). - // - // KNOWN LIMITATION (until the Neon/Postgres backend lands): one-time-use here - // is read-then-delete, and Blobs has no compare-and-swap, so two *simultaneous* - // requests with the same auth code (or refresh token) can both observe it as - // unused before either deletes/marks it — and for refresh tokens a concurrent - // legit+stolen use inside that window would both rotate without tripping family - // revocation. Narrow window (60s code TTL, PKCE-bound). The DB backend fixes it - // with a transactional `UPDATE … WHERE used = false`. - return getStore({ name: STORE, consistency: 'strong' }) -} -const expired = (rec) => !rec || Date.now() > rec.expiresAt - -export async function putAuthRequest(data) { - const id = randomUUID() - await store().setJSON(AR(id), { ...data, expiresAt: Date.now() + AUTH_REQUEST_TTL_SEC * 1000 }) - return id -} - -export async function takeAuthRequest(id) { - if (!id) return null - const key = AR(id) - const rec = await store().get(key, { type: 'json' }).catch(() => null) - await store().delete(key).catch(() => {}) // one-time - return expired(rec) ? null : rec -} - -export async function putAuthCode(data) { - const code = randomBytes(32).toString('base64url') - await store().setJSON(AC(code), { ...data, used: false, expiresAt: Date.now() + AUTH_CODE_TTL_SEC * 1000 }) - return code -} - -export async function takeAuthCode(code) { - if (!code) return null - const key = AC(code) - const rec = await store().get(key, { type: 'json' }).catch(() => null) - if (expired(rec) || rec.used) return null - await store().delete(key).catch(() => {}) // one-time use - return rec -} - -// --- DCR-registered clients (persistent; no TTL) --- -export async function putClient(client) { - await store().setJSON(CL(client.client_id), client) - return client -} - -export async function getStoredClient(clientId) { - if (!clientId) return null - // Resilient: a store error (e.g. Blobs unavailable) resolves to "unknown - // client" rather than crashing the /authorize handler. - try { - return await store().get(CL(clientId), { type: 'json' }) - } catch { - return null - } -} - -// --- refresh tokens (by hash) + families --- -export async function putRefresh(hash, rec) { - await store().setJSON(RT(hash), rec) -} -export async function getRefresh(hash) { - if (!hash) return null - return store().get(RT(hash), { type: 'json' }).catch(() => null) -} -export async function markRefreshUsed(hash) { - const rec = await getRefresh(hash) - if (rec) await store().setJSON(RT(hash), { ...rec, used: true }) -} -export async function putFamily(id, rec) { - await store().setJSON(RTF(id), rec) -} -export async function getFamily(id) { - if (!id) return null - return store().get(RTF(id), { type: 'json' }).catch(() => null) -} -export async function revokeFamily(id) { - const fam = (await getFamily(id)) || {} - await store().setJSON(RTF(id), { ...fam, revoked: true, revokedAt: Date.now() }) -} +// Rollout: deploy with STORE_BACKEND=blobs (default), flip a preview to neon, +// verify, then flip prod. Roll back by resetting the env var — no code revert. +// +// NOTE: flipping blobs→neon does not migrate existing rows, so any live refresh +// tokens (in Blobs) won't exist in Neon — users re-authenticate once at cutover. +// Auth codes (60s TTL) are unaffected in practice. Flip during low traffic. + +import * as blobs from './db/blobs.mjs' +import * as neon from './db/neon.mjs' + +const backend = (process.env.STORE_BACKEND || 'blobs').toLowerCase() === 'neon' ? neon : blobs + +// One-time-use / transactional OAuth state — backend-selectable. +export const putAuthRequest = (...a) => backend.putAuthRequest(...a) +export const takeAuthRequest = (...a) => backend.takeAuthRequest(...a) +export const putAuthCode = (...a) => backend.putAuthCode(...a) +export const takeAuthCode = (...a) => backend.takeAuthCode(...a) +export const putRefresh = (...a) => backend.putRefresh(...a) +export const getRefresh = (...a) => backend.getRefresh(...a) +export const consumeRefresh = (...a) => backend.consumeRefresh(...a) +export const putFamily = (...a) => backend.putFamily(...a) +export const getFamily = (...a) => backend.getFamily(...a) +export const revokeFamily = (...a) => backend.revokeFamily(...a) + +// DCR-registered clients always live on Blobs: they are plain persistence (not +// one-time-use), so the Neon migration's atomicity buys nothing here. Keeping +// them on Blobs keeps the migration surface small. +export const putClient = blobs.putClient +export const getStoredClient = blobs.getStoredClient diff --git a/netlify/functions/mcp-oauth.mjs b/netlify/functions/mcp-oauth.mjs index 332744b1..39438ea8 100644 --- a/netlify/functions/mcp-oauth.mjs +++ b/netlify/functions/mcp-oauth.mjs @@ -12,7 +12,7 @@ import { getJwks, signAccessToken } from './lib/oauth/keys.mjs' import { putAuthRequest, takeAuthRequest, putAuthCode, takeAuthCode, - putRefresh, getRefresh, markRefreshUsed, putFamily, getFamily, revokeFamily, + putRefresh, getRefresh, consumeRefresh, putFamily, getFamily, revokeFamily, } from './lib/oauth/store.mjs' import { buildAuthorizeUrl, exchangeCode, UPSTREAM_MODE } from './lib/oauth/upstream.mjs' import { verifyChallenge, generatePair } from './lib/oauth/pkce.mjs' @@ -258,7 +258,16 @@ export default async (request) => { return json({ error: 'invalid_grant', error_description: 'client_id does not match the refresh token' }, 400) } - await markRefreshUsed(oldHash) // rotate: supersede the presented token + // Atomically consume (supersede) the presented token. On the Neon backend + // this is a single UPDATE…WHERE used=false RETURNING, so only one of two + // concurrent requests wins; the loser gets null and is treated as reuse + // (theft signal), closing the rotation race. On Blobs this is best-effort. + const consumed = await consumeRefresh(oldHash) + if (!consumed) { + await revokeFamily(record.familyId) + return json({ error: 'invalid_grant', error_description: 'refresh token reuse detected; session revoked' }, 400) + } + const refresh_token = await issueRefresh(record.familyId, record.clientId, record.user, record.scope) const access_token = await mintAccess(record.user, record.scope) return json({ access_token, token_type: 'Bearer', expires_in: ACCESS_TOKEN_TTL_SEC, scope: record.scope, refresh_token }) From f28566e9abada2c2a59d576c975f6b82fe03157d Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 22 Jun 2026 11:21:26 +0100 Subject: [PATCH 2/4] Add Neon schema, scheduled cleanup, and atomicity tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration SQL (db/migrations/0001_oauth_state.sql) for the four one-time-use/transactional tables, with expires_at indexes. - cleanupExpired() + a daily scheduled function (oauth-cleanup.mjs) that deletes expired requests/codes and past-expiry refresh tokens, then sweeps empty families. No-ops unless STORE_BACKEND=neon. Bounds growth. - @neondatabase/serverless dependency (HTTP driver; no Drizzle — the atomic ops are single hand-written statements). - Real-Postgres concurrency tests (tests/mcp-oauth-neon.test.ts), skipped unless TEST_NEON_URL is set: prove two concurrent auth-code consumes / refresh rotations yield exactly one winner, and cleanup removes expired rows. A fake can't prove atomicity, so these require a real DB. 56 tests pass; 3 Neon tests skip without a DB URL. --- .../oauth/db/migrations/0001_oauth_state.sql | 52 ++++++++ netlify/functions/lib/oauth/db/neon.mjs | 17 +++ netlify/functions/oauth-cleanup.mjs | 25 ++++ package-lock.json | 124 ++++++++++++++++++ package.json | 3 +- tests/mcp-oauth-neon.test.ts | 80 +++++++++++ 6 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 netlify/functions/lib/oauth/db/migrations/0001_oauth_state.sql create mode 100644 netlify/functions/oauth-cleanup.mjs create mode 100644 tests/mcp-oauth-neon.test.ts diff --git a/netlify/functions/lib/oauth/db/migrations/0001_oauth_state.sql b/netlify/functions/lib/oauth/db/migrations/0001_oauth_state.sql new file mode 100644 index 00000000..d8c71c88 --- /dev/null +++ b/netlify/functions/lib/oauth/db/migrations/0001_oauth_state.sql @@ -0,0 +1,52 @@ +-- OAuth transactional state for the docs MCP authorization server. +-- Applied to the Neon (Netlify DB) database used when STORE_BACKEND=neon. +-- Idempotent: safe to run repeatedly. +-- +-- Scope: the one-time-use / transactional tables only. DCR-registered clients +-- stay on Netlify Blobs (plain persistence, not one-time-use). + +-- In-flight authorization requests (consumed by DELETE … RETURNING). +CREATE TABLE IF NOT EXISTS auth_requests ( + id uuid PRIMARY KEY, + client_id text NOT NULL, + client_redirect_uri text NOT NULL, + client_state text, + client_code_challenge text, + upstream_verifier text, + expires_at timestamptz NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_auth_requests_expires ON auth_requests (expires_at); + +-- Authorization codes (one-time; consumed by atomic UPDATE … WHERE used=false). +CREATE TABLE IF NOT EXISTS auth_codes ( + code text PRIMARY KEY, + client_id text NOT NULL, + client_redirect_uri text NOT NULL, + client_code_challenge text, + user_data jsonb NOT NULL, + used boolean NOT NULL DEFAULT false, + expires_at timestamptz NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_auth_codes_expires ON auth_codes (expires_at); + +-- Refresh tokens, stored by hash (one-time; rotated via atomic UPDATE). +CREATE TABLE IF NOT EXISTS refresh_tokens ( + hash text PRIMARY KEY, + family_id uuid NOT NULL, + client_id text NOT NULL, + user_data jsonb NOT NULL, + scope text, + used boolean NOT NULL DEFAULT false, + expires_at timestamptz NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family ON refresh_tokens (family_id); + +-- Refresh-token families (rotation lineage; revoked on reuse detection). +CREATE TABLE IF NOT EXISTS refresh_families ( + id uuid PRIMARY KEY, + client_id text, + revoked boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + revoked_at timestamptz +); diff --git a/netlify/functions/lib/oauth/db/neon.mjs b/netlify/functions/lib/oauth/db/neon.mjs index f02f02c9..bab9597c 100644 --- a/netlify/functions/lib/oauth/db/neon.mjs +++ b/netlify/functions/lib/oauth/db/neon.mjs @@ -163,3 +163,20 @@ export async function revokeFamily(id) { const sql = await db() await sql`UPDATE refresh_families SET revoked = true, revoked_at = now() WHERE id = ${id}` } + +// TTL cleanup (run on a schedule). Deletes expired one-time-use rows. Only +// past-expiry refresh tokens are removed, so reuse detection still works for +// every token within its lifetime; families with no remaining tokens are then +// swept. Reads already filter on expires_at, so this is purely housekeeping. +export async function cleanupExpired() { + const sql = await db() + const reqs = await sql`DELETE FROM auth_requests WHERE expires_at < now() RETURNING id` + const codes = await sql`DELETE FROM auth_codes WHERE expires_at < now() RETURNING code` + const toks = await sql`DELETE FROM refresh_tokens WHERE expires_at < now() RETURNING hash` + const fams = await sql` + DELETE FROM refresh_families f + WHERE NOT EXISTS (SELECT 1 FROM refresh_tokens t WHERE t.family_id = f.id) + RETURNING id + ` + return { authRequests: reqs.length, authCodes: codes.length, refreshTokens: toks.length, families: fams.length } +} diff --git a/netlify/functions/oauth-cleanup.mjs b/netlify/functions/oauth-cleanup.mjs new file mode 100644 index 00000000..41480446 --- /dev/null +++ b/netlify/functions/oauth-cleanup.mjs @@ -0,0 +1,25 @@ +// Scheduled cleanup of expired OAuth state (Neon backend only). +// +// Runs daily; deletes expired auth requests/codes and past-expiry refresh +// tokens, then sweeps empty token families. No-op unless STORE_BACKEND=neon and +// a database URL is configured, so it's safe to keep deployed during rollout. + +import { cleanupExpired } from './lib/oauth/db/neon.mjs' + +export default async () => { + const backend = (process.env.STORE_BACKEND || 'blobs').toLowerCase() + if (backend !== 'neon' || !process.env.NETLIFY_DATABASE_URL) { + console.log(JSON.stringify({ event: 'oauth_cleanup_skipped', reason: 'not_neon_backend' })) + return + } + try { + const deleted = await cleanupExpired() + console.log(JSON.stringify({ event: 'oauth_cleanup_ran', deleted })) + } catch (e) { + console.warn('[oauth-cleanup] failed', { error: e?.message }) + } +} + +export const config = { + schedule: '@daily', +} diff --git a/package-lock.json b/package-lock.json index 58299089..c740bedc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,13 @@ "@asciidoctor/tabs": "^1.0.0-beta.5", "@modelcontextprotocol/sdk": "1.17.0", "@modelfetch/netlify": "0.15.2", + "@neondatabase/serverless": "^0.10.4", "@netlify/blobs": "^9.1.6", "@redpanda-data/docs-extensions-and-macros": "^5.0.0", "@sntke/antora-mermaid-extension": "^0.0.6", "algoliasearch": "^5.35.0", "hono-rate-limiter": "0.1.0", + "jose": "^5.9.6", "mcpcat": "^0.1.15", "puppeteer": "^24.15.0", "zod": "3.22.4" @@ -1239,6 +1241,15 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@neondatabase/serverless": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-0.10.4.tgz", + "integrity": "sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA==", + "license": "MIT", + "dependencies": { + "@types/pg": "8.11.6" + } + }, "node_modules/@netlify/blobs": { "version": "9.1.6", "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-9.1.6.tgz", @@ -2882,6 +2893,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pg": { + "version": "8.11.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz", + "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -9531,6 +9553,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -11791,6 +11822,12 @@ "node": ">=0.10.0" } }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -12528,6 +12565,48 @@ "integrity": "sha512-rixgxw3SxyJbCaSpo1n35A/fwI1r2rdwMKOTCg/AcG+xOEyZcE8UHVjpZMFCVImzsFoCZeJTT+M/rdEIQYO2nw==", "license": "MIT" }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-numeric": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", + "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.1.0.tgz", + "integrity": "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -12832,6 +12911,51 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "license": "MIT", + "dependencies": { + "obuf": "~1.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postgres-date": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-interval": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-range": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", + "license": "MIT" + }, "node_modules/prepend-http": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-3.0.1.tgz", diff --git a/package.json b/package.json index 364df339..e63d9cf6 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,13 @@ "@asciidoctor/tabs": "^1.0.0-beta.5", "@modelcontextprotocol/sdk": "1.17.0", "@modelfetch/netlify": "0.15.2", + "@neondatabase/serverless": "^0.10.4", "@netlify/blobs": "^9.1.6", - "jose": "^5.9.6", "@redpanda-data/docs-extensions-and-macros": "^5.0.0", "@sntke/antora-mermaid-extension": "^0.0.6", "algoliasearch": "^5.35.0", "hono-rate-limiter": "0.1.0", + "jose": "^5.9.6", "mcpcat": "^0.1.15", "puppeteer": "^24.15.0", "zod": "3.22.4" diff --git a/tests/mcp-oauth-neon.test.ts b/tests/mcp-oauth-neon.test.ts new file mode 100644 index 00000000..34e03ff8 --- /dev/null +++ b/tests/mcp-oauth-neon.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +// Atomicity tests for the Neon backend. These MUST run against a real Postgres +// (a fake honoring atomic semantics would prove nothing), so they're skipped +// unless TEST_NEON_URL points at a disposable test database, e.g.: +// TEST_NEON_URL=postgres://... npx vitest run tests/mcp-oauth-neon.test.ts +const TEST_DB = process.env.TEST_NEON_URL + +const migrationPath = fileURLToPath( + new URL('../netlify/functions/lib/oauth/db/migrations/0001_oauth_state.sql', import.meta.url) +) + +describe.skipIf(!TEST_DB)('Neon backend — atomic one-time-use', () => { + let store: typeof import('../netlify/functions/lib/oauth/db/neon.mjs') + + beforeAll(async () => { + process.env.NETLIFY_DATABASE_URL = TEST_DB + process.env.STORE_BACKEND = 'neon' + + const { neon } = await import('@neondatabase/serverless') + const sql = neon(TEST_DB as string) + // Apply schema, then clear any leftover rows from a previous run. + for (const stmt of readFileSync(migrationPath, 'utf8').split(';').map((s) => s.trim()).filter(Boolean)) { + await sql.query(stmt) + } + await sql.query('TRUNCATE auth_requests, auth_codes, refresh_tokens, refresh_families') + + store = await import('../netlify/functions/lib/oauth/db/neon.mjs') + }) + + it('auth code: two concurrent consumes -> exactly one succeeds', async () => { + const code = await store.putAuthCode({ + clientId: 'c1', + clientRedirectUri: 'https://c1/cb', + clientCodeChallenge: 'chal', + user: { sub: 'u1', email: 'a@b.com' }, + }) + const results = await Promise.all([store.takeAuthCode(code), store.takeAuthCode(code)]) + expect(results.filter(Boolean)).toHaveLength(1) + // A third attempt always fails (already consumed). + expect(await store.takeAuthCode(code)).toBeNull() + }) + + it('refresh token: two concurrent rotations -> exactly one wins (loser = reuse)', async () => { + const familyId = crypto.randomUUID() + const hash = 'hash_' + crypto.randomUUID() + await store.putFamily(familyId, { revoked: false, clientId: 'c1', createdAt: Date.now() }) + await store.putRefresh(hash, { + familyId, + clientId: 'c1', + user: { sub: 'u1' }, + scope: 'openid', + used: false, + expiresAt: Date.now() + 60_000, + }) + + const results = await Promise.all([store.consumeRefresh(hash), store.consumeRefresh(hash)]) + expect(results.filter(Boolean)).toHaveLength(1) // only one rotation wins + expect(await store.consumeRefresh(hash)).toBeNull() // already consumed -> reuse signal + }) + + it('cleanupExpired removes past-expiry rows and empty families', async () => { + const familyId = crypto.randomUUID() + const hash = 'expired_' + crypto.randomUUID() + await store.putFamily(familyId, { revoked: false, clientId: 'c1', createdAt: Date.now() - 120_000 }) + await store.putRefresh(hash, { + familyId, + clientId: 'c1', + user: { sub: 'u1' }, + scope: 'openid', + used: true, + expiresAt: Date.now() - 1_000, // already expired + }) + const deleted = await store.cleanupExpired() + expect(deleted.refreshTokens).toBeGreaterThanOrEqual(1) + expect(await store.getRefresh(hash)).toBeNull() + }) +}) From 9b8a6327c398bfeea9b9b588502e977b909bacee Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 22 Jun 2026 11:38:44 +0100 Subject: [PATCH 3/4] Align Neon store with Netlify DB (db init) conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The database is provisioned and attached to the redpanda-documentation site. Wire the code to Netlify's managed flow: - Use @netlify/database (the package db init installed) instead of the raw @neondatabase/serverless driver: neon.mjs now connects via getDatabase().httpClient (zero-config, reads NETLIFY_DATABASE_URL, fail-closed if absent). - Move the schema into Netlify's auto-applied migrations directory (netlify/database/migrations/), so it's applied on deploy — including to per-preview DB branches. Removes the hand-rolled migrations path. - Update the atomicity test to the new path + @netlify/database client. 56 tests pass; 3 Neon tests skip without TEST_NEON_URL. --- .../20260622110000_oauth_state.sql} | 0 netlify/functions/lib/oauth/db/neon.mjs | 13 +- package-lock.json | 288 ++++++++++++++---- package.json | 2 +- tests/mcp-oauth-neon.test.ts | 6 +- 5 files changed, 234 insertions(+), 75 deletions(-) rename netlify/{functions/lib/oauth/db/migrations/0001_oauth_state.sql => database/migrations/20260622110000_oauth_state.sql} (100%) diff --git a/netlify/functions/lib/oauth/db/migrations/0001_oauth_state.sql b/netlify/database/migrations/20260622110000_oauth_state.sql similarity index 100% rename from netlify/functions/lib/oauth/db/migrations/0001_oauth_state.sql rename to netlify/database/migrations/20260622110000_oauth_state.sql diff --git a/netlify/functions/lib/oauth/db/neon.mjs b/netlify/functions/lib/oauth/db/neon.mjs index bab9597c..6592c839 100644 --- a/netlify/functions/lib/oauth/db/neon.mjs +++ b/netlify/functions/lib/oauth/db/neon.mjs @@ -10,8 +10,10 @@ // Scope: the four one-time-use / transactional tables only. DCR clients stay on // Blobs (they are plain persistence, not one-time-use — no atomicity benefit). // -// Driver is imported lazily so this module can be loaded without the dependency -// or a database URL present (e.g. when STORE_BACKEND=blobs in tests/CI). +// Uses @netlify/database's zero-config client (getDatabase().httpClient), which +// reads NETLIFY_DATABASE_URL automatically and is the HTTP Neon query function +// (tagged-template SQL). Imported lazily so this module can be loaded without +// the dependency or a database URL present (e.g. STORE_BACKEND=blobs in tests). import { randomUUID, randomBytes } from 'node:crypto' import { AUTH_REQUEST_TTL_SEC, AUTH_CODE_TTL_SEC } from '../config.mjs' @@ -19,10 +21,9 @@ import { AUTH_REQUEST_TTL_SEC, AUTH_CODE_TTL_SEC } from '../config.mjs' let _sql = null async function db() { if (_sql) return _sql - const url = process.env.NETLIFY_DATABASE_URL - if (!url) throw new Error('NETLIFY_DATABASE_URL is not set (STORE_BACKEND=neon requires a provisioned Neon DB)') - const { neon } = await import('@neondatabase/serverless') - _sql = neon(url) + const { getDatabase } = await import('@netlify/database') + // Throws MissingDatabaseConnectionError if no URL is configured (fail-closed). + _sql = getDatabase().httpClient return _sql } diff --git a/package-lock.json b/package-lock.json index c740bedc..d6024173 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,8 @@ "@asciidoctor/tabs": "^1.0.0-beta.5", "@modelcontextprotocol/sdk": "1.17.0", "@modelfetch/netlify": "0.15.2", - "@neondatabase/serverless": "^0.10.4", "@netlify/blobs": "^9.1.6", + "@netlify/database": "^1.0.0", "@redpanda-data/docs-extensions-and-macros": "^5.0.0", "@sntke/antora-mermaid-extension": "^0.0.6", "algoliasearch": "^5.35.0", @@ -1242,12 +1242,12 @@ } }, "node_modules/@neondatabase/serverless": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-0.10.4.tgz", - "integrity": "sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.1.0.tgz", + "integrity": "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q==", "license": "MIT", - "dependencies": { - "@types/pg": "8.11.6" + "engines": { + "node": ">=19.0.0" } }, "node_modules/@netlify/blobs": { @@ -1263,6 +1263,52 @@ "node": "^14.16.0 || >=16.0.0" } }, + "node_modules/@netlify/database": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@netlify/database/-/database-1.0.0.tgz", + "integrity": "sha512-k4rft5pkJEXp3Ps1HeuaK2F4a5wuFMeTlMr8PHfRVAdXjit+OsWAqcEqM9mk0lFyyh4zAWNZLXinrQhlZHpUBQ==", + "license": "MIT", + "dependencies": { + "@neondatabase/serverless": "^1.1.0", + "@netlify/runtime-utils": "2.3.0", + "pg": "^8.13.0", + "waddler": "^0.1.1", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.6.1" + } + }, + "node_modules/@netlify/database/node_modules/@netlify/runtime-utils": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.3.0.tgz", + "integrity": "sha512-cW8weDvsKV7zfia2m5EcBy6KILGoPD+eYZ3qWNGnIo05DGF28goPES0xKSDkNYgAF/2rRSIhie2qcBhbGVgSRg==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || >=20" + } + }, + "node_modules/@netlify/database/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@netlify/dev-utils": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-3.2.0.tgz", @@ -2893,17 +2939,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/pg": { - "version": "8.11.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz", - "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^4.0.1" - } - }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -11822,12 +11857,6 @@ "node": ">=0.10.0" } }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "license": "MIT" - }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -12565,6 +12594,46 @@ "integrity": "sha512-rixgxw3SxyJbCaSpo1n35A/fwI1r2rdwMKOTCg/AcG+xOEyZcE8UHVjpZMFCVImzsFoCZeJTT+M/rdEIQYO2nw==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -12574,13 +12643,13 @@ "node": ">=4.0.0" } }, - "node_modules/pg-numeric": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", - "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", - "license": "ISC", - "engines": { - "node": ">=4" + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" } }, "node_modules/pg-protocol": { @@ -12590,21 +12659,28 @@ "license": "MIT" }, "node_modules/pg-types": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.1.0.tgz", - "integrity": "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", "license": "MIT", "dependencies": { "pg-int8": "1.0.1", - "pg-numeric": "1.0.2", - "postgres-array": "~3.0.1", - "postgres-bytea": "~3.0.0", - "postgres-date": "~2.1.0", - "postgres-interval": "^3.0.0", - "postgres-range": "^1.1.1" + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" }, "engines": { - "node": ">=10" + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" } }, "node_modules/picocolors": { @@ -12912,50 +12988,44 @@ "license": "MIT" }, "node_modules/postgres-array": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", - "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "license": "MIT", "engines": { - "node": ">=12" + "node": ">=4" } }, "node_modules/postgres-bytea": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", - "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", "license": "MIT", - "dependencies": { - "obuf": "~1.1.2" - }, "engines": { - "node": ">= 6" + "node": ">=0.10.0" } }, "node_modules/postgres-date": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", - "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "license": "MIT", "engines": { - "node": ">=12" + "node": ">=0.10.0" } }, "node_modules/postgres-interval": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", - "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, "engines": { - "node": ">=12" + "node": ">=0.10.0" } }, - "node_modules/postgres-range": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", - "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", - "license": "MIT" - }, "node_modules/prepend-http": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-3.0.1.tgz", @@ -16548,6 +16618,94 @@ "node": ">=18" } }, + "node_modules/waddler": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/waddler/-/waddler-0.1.1.tgz", + "integrity": "sha512-lBJXYFBLEpYe+scAeCJmLj6Iqweuq1whM6Am3I9WfopOCFxvKz8Nq5hXoy8/b3zwJqHIQMglFIvM4skRydSpZg==", + "license": "MIT", + "peerDependencies": { + "@clickhouse/client": "^1.11.2", + "@duckdb/node-api": "^1.1.2-alpha.4", + "@electric-sql/pglite": "^0.2.17", + "@libsql/client": "^0.15.4", + "@libsql/client-wasm": "^0.15.4", + "@neondatabase/serverless": "^1.0.0", + "@planetscale/database": "^1.19.0", + "@tidbcloud/serverless": "^0.2.0", + "@vercel/postgres": "^0.10.0", + "@xata.io/client": "^0.30.1", + "better-sqlite3": "^11.9.1", + "bun-types": "*", + "duckdb": "^1.2.1", + "gel": "^2.0.2", + "mysql2": "^3.14.0", + "pg": "^8.14.0", + "pg-query-stream": "^4.8.0", + "postgres": "^3.4.5" + }, + "peerDependenciesMeta": { + "@clickhouse/client": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@duckdb/node-api": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "duckdb": { + "optional": true + }, + "gel": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "postgres": { + "optional": true + } + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/package.json b/package.json index e63d9cf6..c1ef42ec 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,8 @@ "@asciidoctor/tabs": "^1.0.0-beta.5", "@modelcontextprotocol/sdk": "1.17.0", "@modelfetch/netlify": "0.15.2", - "@neondatabase/serverless": "^0.10.4", "@netlify/blobs": "^9.1.6", + "@netlify/database": "^1.0.0", "@redpanda-data/docs-extensions-and-macros": "^5.0.0", "@sntke/antora-mermaid-extension": "^0.0.6", "algoliasearch": "^5.35.0", diff --git a/tests/mcp-oauth-neon.test.ts b/tests/mcp-oauth-neon.test.ts index 34e03ff8..62f3f18c 100644 --- a/tests/mcp-oauth-neon.test.ts +++ b/tests/mcp-oauth-neon.test.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url' const TEST_DB = process.env.TEST_NEON_URL const migrationPath = fileURLToPath( - new URL('../netlify/functions/lib/oauth/db/migrations/0001_oauth_state.sql', import.meta.url) + new URL('../netlify/database/migrations/20260622110000_oauth_state.sql', import.meta.url) ) describe.skipIf(!TEST_DB)('Neon backend — atomic one-time-use', () => { @@ -19,8 +19,8 @@ describe.skipIf(!TEST_DB)('Neon backend — atomic one-time-use', () => { process.env.NETLIFY_DATABASE_URL = TEST_DB process.env.STORE_BACKEND = 'neon' - const { neon } = await import('@neondatabase/serverless') - const sql = neon(TEST_DB as string) + const { getDatabase } = await import('@netlify/database') + const sql = getDatabase().httpClient // zero-config, reads NETLIFY_DATABASE_URL // Apply schema, then clear any leftover rows from a previous run. for (const stmt of readFileSync(migrationPath, 'utf8').split(';').map((s) => s.trim()).filter(Boolean)) { await sql.query(stmt) From 3a810895655a0df2bff095253773ab54abf5c0f9 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 22 Jun 2026 11:53:35 +0100 Subject: [PATCH 4/4] chore: trigger deploy-preview build (pick up STORE_BACKEND=neon)