From 3639ffdb91e7106ac2c3db2490e4e93e20c751bb Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Tue, 26 May 2026 16:19:13 -0700 Subject: [PATCH 1/2] v4-hook-repro: confirm onResolveProvider hook fires via resources.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors CM's hook registration pattern in a sibling repro script to the existing v4-routing-repro. The fixture: - config.yaml declares jsResource:resources.js (auto-load) + the OAuth plugin with one static provider literally named "oauth" (kept as a decoy for routing regressions). - resources.js calls `registerHooks({ onResolveProvider: ... })` at module top level. The hook returns a generic provider config for any oac-* providerName. The script hits /oauth/oac-test-config/login and asserts the redirect went to the URL the hook returned — empirical proof that on harperdb v4 the hook DOES fire when registered from a sibling resources.js. Why this matters. We previously suspected (from a v5 fixture attempt on oauth main) that the same registerHooks-from-resources.js pattern suffers from module isolation between resources.js's import of @harperfast/oauth and the plugin's own pluginModule load. CM demonstrably works in production, but on harper v5 the same pattern broke. This script narrows that to a v4-vs-v5 component-loader behavior split — and confirms that CM's setup is correct for v4. For Dawson's actual 500: the hook IS registered on v4 (this script proves it), so the 500 must come from inside CM's handleResolveProvider itself — most likely a SecretManager.decrypt failure or DB error. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/v4-hook-repro.mjs | 394 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 scripts/v4-hook-repro.mjs diff --git a/scripts/v4-hook-repro.mjs b/scripts/v4-hook-repro.mjs new file mode 100644 index 0000000..ed9d517 --- /dev/null +++ b/scripts/v4-hook-repro.mjs @@ -0,0 +1,394 @@ +#!/usr/bin/env node +/** + * v4-hook-repro.mjs — does an onResolveProvider hook registered from a + * sibling resources.js (the CM pattern) actually fire on harperdb v4? + * + * Background. CM's resources.js does: + * + * import { registerHooks } from '@harperfast/oauth'; + * import { hooks } from './src/lib/oauthHooks.js'; + * registerHooks(hooks); + * + * at module top level, then the hooks fire when /oauth/{configId}/login + * runs. We tried mirroring that pattern in an oauth v5 integration test + * (HarperFast/oauth `dynamic-provider-app` fixture, see PR #102 history) + * and the hook never got invoked — strong evidence of module isolation + * between resources.js' import of @harperfast/oauth and the plugin's + * own pluginModule load. + * + * But CM clearly works in production (the user hits the resolver path + * and sometimes gets a 500 from inside the hook — a 500 only fires if + * the hook was registered AND threw). So either: + * (a) harperdb v4's component loader doesn't isolate modules between + * sub-component loads, while harper v5's does + * (b) CM's setup is subtly different from the v5 fixture in a way + * that bridges the isolation + * (c) Something else + * + * This script tests (a) directly: boot harperdb v4 with a fixture + * that mirrors CM's resources.js pattern, then GET /oauth/{oac-id}/login + * and inspect the response. + * + * - 302 to http://hook-resolved.test/authorize with the hook's + * declared client_id → hook fires. Consistent with CM working; + * v4 doesn't have the isolation that v5 does. + * - 404 "OAuth provider not found" → hook isn't registered in the + * plugin's running instance. Would mean CM should also be broken + * — investigate what we're missing. + * - 500 "Failed to resolve OAuth provider" → hook fires AND throws. + * Not the path we're testing here (our hook returns cleanly), but + * a useful surface to know is available. + * + * Sibling: scripts/v4-routing-repro.mjs (static-provider routing). + * + * Run manually: + * node scripts/v4-hook-repro.mjs + * + * Set KEEP_TEMP=1 to preserve the temp dir for inspection. + */ +import { mkdtempSync, writeFileSync, rmSync, existsSync, copyFileSync } from 'node:fs'; +import { tmpdir, homedir } from 'node:os'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn, spawnSync } from 'node:child_process'; + +const BOOT_PROPS_PATH = join(homedir(), '.harperdb', 'hdb_boot_properties.file'); + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const harperBin = join(repoRoot, 'node_modules', '.bin', 'harperdb'); +const tscBin = join(repoRoot, 'node_modules', '.bin', 'tsc'); + +// Pin the fixture's harperdb peer to the exact version we're spawning so +// `npm install` doesn't resolve a drifted version from the registry. +const HARPERDB_PIN = '4.7.19'; // SOURCE-OF-TRUTH: node_modules/harperdb/package.json + +// The hook returns this client_id; the test asserts it round-tripped +// through to the authorize URL the plugin emits. +const HOOK_CLIENT_ID = 'hook-resolved-client-id-42'; + +// Static provider — needed only to keep the OAuth plugin from registering +// its no-providers stub resource. Naming it 'oauth' here ALSO doubles as +// a decoy: if a future routing regression returns the literal "oauth" +// prefix as providerName, requests would route here instead of into the +// hook, and we'd see the static-stub authorize URL in the redirect. +const STATIC_DECOY_CLIENT_ID = 'static-decoy-client-id'; + +const CONFIG_YAML = ` +rest: true + +# Auto-load resources.js (CM does this too). +jsResource: + files: resources.js + +'@harperfast/oauth': + package: '@harperfast/oauth' + providers: + oauth: + provider: generic + clientId: ${STATIC_DECOY_CLIENT_ID} + clientSecret: decoy-secret + authorizationUrl: 'http://static.test/authorize' + tokenUrl: 'http://static.test/token' + userInfoUrl: 'http://static.test/userinfo' +`.trimStart(); + +// resources.js mirrors CM's pattern: top-level registerHooks call, hook +// returns a provider config for oac-prefixed IDs. +const RESOURCES_JS = ` +import { registerHooks } from '@harperfast/oauth'; + +registerHooks({ + async onResolveProvider(providerName, logger) { + // Visible in stdout so we can tell empirically whether the hook fires. + console.log('[hook-repro] onResolveProvider called with: ' + providerName); + if (providerName.startsWith('oac-')) { + return { + provider: 'generic', + clientId: ${JSON.stringify(HOOK_CLIENT_ID)}, + clientSecret: 'hook-resolved-secret', + authorizationUrl: 'http://hook-resolved.test/authorize', + tokenUrl: 'http://hook-resolved.test/token', + userInfoUrl: 'http://hook-resolved.test/userinfo', + scope: 'openid profile email', + }; + } + return null; + }, +}); + +console.log('[hook-repro] resources.js: registerHooks invoked'); +`.trimStart(); + +const PACKAGE_JSON = JSON.stringify( + { + name: 'oauth-v4-hook-repro', + private: true, + type: 'module', + devDependencies: { harperdb: HARPERDB_PIN }, + }, + null, + 2 +); + +function run(cmd, args, opts = {}) { + const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts }); + if (result.status !== 0) { + throw new Error(`${cmd} ${args.join(' ')} exited ${result.status}`); + } +} + +function log(...args) { + console.log('[v4-hook-repro]', ...args); +} + +async function waitForReady(harperProc, timeoutMs) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + let buf = ''; + const onData = (chunk) => { + const s = String(chunk); + buf += s; + process.stdout.write(s); + if (/successfully started/i.test(buf)) { + cleanup(); + resolve(); + } + }; + const onExit = (code) => { + cleanup(); + reject(new Error(`harperdb exited prematurely with code ${code}`)); + }; + const onError = (err) => { + cleanup(); + reject(new Error(`harperdb spawn error: ${err.message}`)); + }; + const onTimeout = () => { + cleanup(); + reject(new Error(`harperdb did not become ready within ${timeoutMs}ms`)); + }; + const timer = setTimeout(onTimeout, deadline - Date.now()); + const cleanup = () => { + clearTimeout(timer); + harperProc.stdout?.off('data', onData); + harperProc.stderr?.off('data', onData); + harperProc.off('exit', onExit); + harperProc.off('error', onError); + }; + harperProc.stdout?.on('data', onData); + harperProc.stderr?.on('data', onData); + harperProc.once('exit', onExit); + harperProc.once('error', onError); + }); +} + +const cleanupState = { + bootBackupPath: null, + bootState: 'pending', + tempRoot: null, + harperProc: null, + cleaned: false, +}; + +function backupBootProps() { + if (existsSync(cleanupState.bootBackupPath)) { + log(`WARNING: backup at ${cleanupState.bootBackupPath} already exists; treating as authoritative`); + cleanupState.bootState = 'existed'; + return; + } + if (existsSync(BOOT_PROPS_PATH)) { + copyFileSync(BOOT_PROPS_PATH, cleanupState.bootBackupPath); + log(`saved boot props to ${cleanupState.bootBackupPath}`); + cleanupState.bootState = 'existed'; + return; + } + log('no existing boot props file to back up'); + cleanupState.bootState = 'absent'; +} + +function restoreBootProps() { + const { bootBackupPath, bootState } = cleanupState; + try { + if (bootState === 'existed') { + if (bootBackupPath && existsSync(bootBackupPath)) { + copyFileSync(bootBackupPath, BOOT_PROPS_PATH); + log(`restored boot props from backup`); + } else { + log(`WARNING: backup at ${bootBackupPath} missing — leaving boot props as-is`); + } + } else if (bootState === 'absent') { + rmSync(BOOT_PROPS_PATH, { force: true }); + log(`removed boot props (none existed before)`); + } + } catch (err) { + log(`WARNING: failed to restore boot props: ${err.message}. Backup may be at ${bootBackupPath}`); + } +} + +function cleanup() { + if (cleanupState.cleaned) return; + cleanupState.cleaned = true; + const { harperProc, tempRoot } = cleanupState; + if (harperProc && harperProc.exitCode === null) { + log('killing harperdb...'); + try { + harperProc.kill('SIGINT'); + } catch { + // Already exited. + } + setTimeout(() => { + if (harperProc.exitCode === null) { + try { + harperProc.kill('SIGKILL'); + } catch { + // Already exited. + } + } + }, 1000); + } + restoreBootProps(); + if (tempRoot && !process.env.KEEP_TEMP) { + log(`cleaning up ${tempRoot}`); + try { + rmSync(tempRoot, { recursive: true, force: true }); + } catch (err) { + log(`WARNING: failed to remove ${tempRoot}: ${err.message}`); + } + } else if (tempRoot) { + log(`KEEP_TEMP set; leaving ${tempRoot}`); + } +} + +async function main() { + if (!existsSync(harperBin)) { + throw new Error(`harperdb binary not found at ${harperBin}. Run "npm install" in the repo root first.`); + } + if (!existsSync(tscBin)) { + throw new Error(`tsc binary not found at ${tscBin}. Run "npm install" in the repo root first.`); + } + + cleanupState.tempRoot = mkdtempSync(join(tmpdir(), 'oauth-v4-hook-repro-')); + cleanupState.bootBackupPath = join(cleanupState.tempRoot, 'hdb_boot_properties.file.bak'); + const componentsRoot = join(cleanupState.tempRoot, 'components'); + const componentAppDir = join(componentsRoot, 'app'); + const hdbRoot = join(cleanupState.tempRoot, 'hdb-root'); + log(`temp root: ${cleanupState.tempRoot}`); + + backupBootProps(); + + let exitCode = 1; + + try { + log('rebuilding plugin (strict)...'); + rmSync(join(repoRoot, 'dist'), { recursive: true, force: true }); + run(tscBin, [], { cwd: repoRoot }); + const distEntry = join(repoRoot, 'dist', 'index.js'); + if (!existsSync(distEntry)) { + throw new Error(`build did not emit ${distEntry}`); + } + + log('packing local plugin...'); + const packResult = spawnSync('npm', ['pack', '--pack-destination', cleanupState.tempRoot, '--json'], { + cwd: repoRoot, + encoding: 'utf8', + }); + if (packResult.status !== 0) { + console.error(packResult.stderr); + throw new Error(`npm pack failed (exit ${packResult.status})`); + } + const tarballName = JSON.parse(packResult.stdout)[0].filename; + const tarballPath = join(cleanupState.tempRoot, tarballName); + + log(`writing fixture to ${componentAppDir}...`); + run('mkdir', ['-p', componentAppDir]); + writeFileSync(join(componentAppDir, 'config.yaml'), CONFIG_YAML); + writeFileSync(join(componentAppDir, 'package.json'), PACKAGE_JSON); + writeFileSync(join(componentAppDir, 'resources.js'), RESOURCES_JS); + + log('installing plugin into fixture component dir...'); + run('npm', ['install', '--no-save', '--no-audit', '--no-fund', tarballPath], { cwd: componentAppDir }); + + const httpPort = 19926; + const opsPort = 19925; + const hostname = '127.0.0.1'; + const args = [ + `--ROOTPATH=${hdbRoot}`, + `--TC_AGREEMENT=yes`, + `--HDB_ADMIN_USERNAME=admin`, + `--HDB_ADMIN_PASSWORD=Abc1234!`, + `--DEFAULTS_MODE=dev`, + `--REPLICATION_HOSTNAME=localhost`, + `--HTTP_PORT=${hostname}:${httpPort}`, + `--OPERATIONSAPI_NETWORK_PORT=${hostname}:${opsPort}`, + `--NODE_HOSTNAME=${hostname}`, + `--THREADS_COUNT=1`, + `--LOGGING_LEVEL=debug`, + `--LOGGING_STDSTREAMS=true`, + `--CLUSTERING_ENABLED=false`, + `--COMPONENTSROOT=${componentsRoot}`, + ]; + + log(`spawning: ${harperBin} ${args.join(' ')}`); + cleanupState.harperProc = spawn(harperBin, args); + + log('waiting for ready...'); + await waitForReady(cleanupState.harperProc, 60_000); + + // Hit a configId that is NOT in the static registry. If the hook + // fires we get a 302 to hook-resolved.test/authorize with the + // hook's client_id. If the hook doesn't fire we get 404. + const url = `http://${hostname}:${httpPort}/oauth/oac-test-config/login`; + log(`fetching ${url}`); + const response = await fetch(url, { redirect: 'manual' }); + log(`status: ${response.status}`); + const location = response.headers.get('location'); + log(`location: ${location}`); + + if (response.status === 404) { + log('FAIL: 404 — hook did not fire (likely module isolation; CM would be broken too?)'); + exitCode = 1; + } else if (response.status !== 302 || !location) { + const body = await response.text().catch(() => ''); + log(`FAIL: expected 302, got ${response.status}: ${body}`); + exitCode = 1; + } else { + const redirectUrl = new URL(location); + const target = redirectUrl.origin + redirectUrl.pathname; + const clientId = redirectUrl.searchParams.get('client_id'); + log(`redirect target: ${target}`); + log(`client_id: ${clientId}`); + + if (target === 'http://hook-resolved.test/authorize' && clientId === HOOK_CLIENT_ID) { + log('PASS: hook fired and returned config; the plugin built the authorize URL from it'); + exitCode = 0; + } else if (target === 'http://static.test/authorize' && clientId === STATIC_DECOY_CLIENT_ID) { + log('FAIL: routed to the static decoy provider — parseRoute regression returned literal "oauth"'); + exitCode = 1; + } else { + log(`UNEXPECTED: target=${target} client_id=${clientId}`); + exitCode = 1; + } + } + } catch (err) { + log('error:', err.message); + exitCode = 1; + } finally { + cleanup(); + await new Promise((r) => setTimeout(r, 1100)); + } + + process.exit(exitCode); +} + +for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT']) { + process.on(sig, () => { + cleanup(); + const code = sig === 'SIGINT' ? 130 : sig === 'SIGTERM' ? 143 : sig === 'SIGHUP' ? 129 : 131; + setTimeout(() => process.exit(code), 1100).unref(); + }); +} + +main().catch((err) => { + console.error('[v4-hook-repro] fatal:', err); + cleanup(); + process.exit(1); +}); From 9218c320e6feb9ee7c05820a91f53a28fdf9495e Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Tue, 26 May 2026 16:25:19 -0700 Subject: [PATCH 2/2] v4-hook-repro: add throwing-hook case (Dawson's exact symptom) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second test case to v4-hook-repro: when the hook throws, the plugin surfaces the harperdb-v4 error envelope HTTP/1.1 200 OK Content-Type: application/json {"status":500,"body":{"error":"Failed to resolve OAuth provider"}} which is byte-for-byte what Dawson pasted in Slack on 2026-05-22. We were reading that string as an HTTP 500 the whole time; it's actually an HTTP 200 with the plugin's intended status nested in the body — harperdb v4 doesn't translate a Resource's `return { status: 500, ... }` to an HTTP 500 (harper v5 does). This finalizes the repro: case 1: hook fires and returns config → 302 to the hook's URL case 2: hook throws → v4 envelope with 'Failed to resolve OAuth provider' as the inner body For Dawson's investigation: the actual throw on his system is inside CM's handleResolveProvider — SecretManager.decrypt, OrganizationOAuthConfig.get, or SecretManager.create. To narrow further, CM needs try/catch + log inside that function on his deployment. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/v4-hook-repro.mjs | 119 +++++++++++++++++++++++++++----------- 1 file changed, 86 insertions(+), 33 deletions(-) diff --git a/scripts/v4-hook-repro.mjs b/scripts/v4-hook-repro.mjs index ed9d517..5967716 100644 --- a/scripts/v4-hook-repro.mjs +++ b/scripts/v4-hook-repro.mjs @@ -93,7 +93,10 @@ jsResource: `.trimStart(); // resources.js mirrors CM's pattern: top-level registerHooks call, hook -// returns a provider config for oac-prefixed IDs. +// resolves oac-prefixed IDs. The 'oac-throw-test' branch deliberately +// throws so we can assert the plugin surfaces a 500 — the same shape +// Dawson saw when CM's real handleResolveProvider threw (e.g., on a +// SecretManager.decrypt failure or a corrupted record). const RESOURCES_JS = ` import { registerHooks } from '@harperfast/oauth'; @@ -101,6 +104,12 @@ registerHooks({ async onResolveProvider(providerName, logger) { // Visible in stdout so we can tell empirically whether the hook fires. console.log('[hook-repro] onResolveProvider called with: ' + providerName); + if (providerName === 'oac-throw-test') { + // Simulates the inside-the-hook failure mode Dawson hit (the + // plugin's catch block converts a thrown hook into a 500 with + // body { error: 'Failed to resolve OAuth provider' }). + throw new Error('simulated downstream failure (e.g., decrypt / DB)'); + } if (providerName.startsWith('oac-')) { return { provider: 'generic', @@ -333,41 +342,85 @@ async function main() { log('waiting for ready...'); await waitForReady(cleanupState.harperProc, 60_000); - // Hit a configId that is NOT in the static registry. If the hook - // fires we get a 302 to hook-resolved.test/authorize with the - // hook's client_id. If the hook doesn't fire we get 404. - const url = `http://${hostname}:${httpPort}/oauth/oac-test-config/login`; - log(`fetching ${url}`); - const response = await fetch(url, { redirect: 'manual' }); - log(`status: ${response.status}`); - const location = response.headers.get('location'); - log(`location: ${location}`); - - if (response.status === 404) { - log('FAIL: 404 — hook did not fire (likely module isolation; CM would be broken too?)'); - exitCode = 1; - } else if (response.status !== 302 || !location) { - const body = await response.text().catch(() => ''); - log(`FAIL: expected 302, got ${response.status}: ${body}`); - exitCode = 1; - } else { - const redirectUrl = new URL(location); - const target = redirectUrl.origin + redirectUrl.pathname; - const clientId = redirectUrl.searchParams.get('client_id'); - log(`redirect target: ${target}`); - log(`client_id: ${clientId}`); - - if (target === 'http://hook-resolved.test/authorize' && clientId === HOOK_CLIENT_ID) { - log('PASS: hook fired and returned config; the plugin built the authorize URL from it'); - exitCode = 0; - } else if (target === 'http://static.test/authorize' && clientId === STATIC_DECOY_CLIENT_ID) { - log('FAIL: routed to the static decoy provider — parseRoute regression returned literal "oauth"'); - exitCode = 1; + const base = `http://${hostname}:${httpPort}`; + const results = []; + + // Test 1: hook returns a config → plugin redirects to it. + { + const url = `${base}/oauth/oac-test-config/login`; + log(`[case 1] fetching ${url}`); + const response = await fetch(url, { redirect: 'manual' }); + log(`[case 1] status: ${response.status}`); + const location = response.headers.get('location'); + log(`[case 1] location: ${location}`); + + let result; + if (response.status === 404) { + result = 'FAIL: 404 — hook did not fire (module isolation? CM would be broken too)'; + } else if (response.status !== 302 || !location) { + const body = await response.text().catch(() => ''); + result = `FAIL: expected 302, got ${response.status}: ${body}`; } else { - log(`UNEXPECTED: target=${target} client_id=${clientId}`); - exitCode = 1; + const redirectUrl = new URL(location); + const target = redirectUrl.origin + redirectUrl.pathname; + const clientId = redirectUrl.searchParams.get('client_id'); + log(`[case 1] redirect target: ${target}, client_id: ${clientId}`); + if (target === 'http://hook-resolved.test/authorize' && clientId === HOOK_CLIENT_ID) { + result = 'PASS: hook fired and returned config; plugin built the authorize URL from it'; + } else if (target === 'http://static.test/authorize' && clientId === STATIC_DECOY_CLIENT_ID) { + result = 'FAIL: routed to the static decoy — parseRoute regression returned literal "oauth"'; + } else { + result = `FAIL UNEXPECTED: target=${target} client_id=${clientId}`; + } } + log(`[case 1] ${result}`); + results.push(result); } + + // Test 2: hook throws → plugin emits its 500 error envelope. This + // mirrors Dawson's actual failure shape (his handleResolveProvider + // throws somewhere downstream — likely a SecretManager.decrypt or + // DB error). The OAuth Resource returns `{ status: 500, body: ...}` + // from a Resource method; harperdb v4 surfaces that as an HTTP 200 + // with the envelope as the JSON body. The string Dawson pasted in + // the Slack thread: + // + // {"status":500,"body":{"error":"Failed to resolve OAuth provider"}} + // + // is exactly that body, NOT an HTTP 500. Asserting on the inner + // envelope is the right shape for this stack. (If/when CM migrates + // to harper v5, the outer HTTP status would also be 500; a v5 + // counterpart of this script should assert on response.status.) + { + const url = `${base}/oauth/oac-throw-test/login`; + log(`[case 2] fetching ${url}`); + const response = await fetch(url, { redirect: 'manual' }); + log(`[case 2] http status: ${response.status}`); + const body = await response.json().catch(() => null); + log(`[case 2] body: ${JSON.stringify(body)}`); + + let result; + if (response.status !== 200) { + result = `FAIL: expected HTTP 200 (harperdb v4 envelope), got ${response.status}`; + } else if (!body || body.status !== 500) { + result = `FAIL: expected envelope body.status === 500, got ${JSON.stringify(body)}`; + } else if (body.body?.error !== 'Failed to resolve OAuth provider') { + result = `FAIL: expected body.body.error === 'Failed to resolve OAuth provider', got ${JSON.stringify(body)}`; + } else { + result = + 'PASS: thrown hook surfaces as v4 envelope { status:500, body:{ error:"Failed to resolve OAuth provider" } } — exactly Dawson\'s symptom'; + } + log(`[case 2] ${result}`); + results.push(result); + } + + // Aggregate result + const allPassed = results.every((r) => r.startsWith('PASS')); + exitCode = allPassed ? 0 : 1; + log(''); + log('=== Summary ==='); + results.forEach((r, i) => log(` case ${i + 1}: ${r}`)); + log(`overall: ${allPassed ? 'PASS' : 'FAIL'}`); } catch (err) { log('error:', err.message); exitCode = 1;