diff --git a/docs/social-proof-consent.md b/docs/social-proof-consent.md new file mode 100644 index 0000000..acb87ed --- /dev/null +++ b/docs/social-proof-consent.md @@ -0,0 +1,85 @@ +# Consent-based social proof workflow + +This operator-only workflow reviews the already-approved minimal installation records without +saving another copy of their identities. It never sends messages, exposes a public endpoint, or +authorizes public display from an installation alone. + +## Safety boundary + +- Run these commands only on a trusted local machine. +- Keep the registry and account-fingerprint key outside the repository. Both files are created + with owner-only permissions and will not overwrite an existing file. The command refuses paths + inside the repository, including paths reached through a symlinked parent directory, and refuses + input files readable or writable by other users. +- Never commit the registry, fingerprint key, permission evidence, or installation identities. +- Do not add repository information, usage frequency, issue counts, email addresses, or other + enrichment to this workflow. +- Contact an app owner at most once unless they reply. Record `contacted`, `declined`, `approved`, + or `withdrawn` before reviewing candidates again. +- Publishing requires affirmative permission from an authorized representative. Copy only the + exact approved name, URL, logo URL, quote, and attribution into `publicProfile`. + +## Create the private registry + +Create the registry and its separate fingerprint key together: + +```sh +npm run social-proof:consent -- init \ + --registry /private/path/bugdrop-consent.json \ + --key /private/path/bugdrop-social-proof.key +``` + +Back up the key securely. It lets the workflow recognize a prior decision after an app is +reinstalled, but the registry itself stores only a keyed account fingerprint—not the GitHub login, +profile link, or installation ID. The registry includes a non-secret key verifier, and review fails +instead of resurfacing prior decisions if the wrong key is supplied. + +## Review outreach candidates + +Authenticate Wrangler with read access to the production Cloudflare KV namespace, then run: + +```sh +npm run social-proof:consent -- review \ + --registry /private/path/bugdrop-consent.json \ + --key /private/path/bugdrop-social-proof.key \ + --exclude mean-weasel,neonwatty +``` + +The private terminal displays the currently eligible account, profile, installation date, and its +keyed fingerprint. The command does not save those installation identities to another file. Close +the terminal session after finishing the review. The command rejects installation records with any +fields beyond the approved minimal schema. The exclusion list is required so owned and controlled +test accounts cannot accidentally enter the outreach queue. + +## Record decisions + +Non-approved entries contain exactly `accountFingerprint`, `status`, and `updatedAt`. Copy the +fingerprint shown by the review command; do not copy the account login or profile. For example: + +```json +{ + "accountFingerprint": "9df00f4d0ea916a50368e409184430b50cb1f8c17eae5bce7695b9ff706829be", + "status": "declined", + "updatedAt": "2026-08-30T00:00:00.000Z" +} +``` + +Because the fingerprint is keyed, it continues to suppress repeat outreach if the same account +reinstalls with a new GitHub installation ID, without retaining a directly identifying account +value in the registry. + +An approved entry also contains `approval`, including the approval date, a private evidence +reference, confirmation that the person was authorized, and the exact public profile they +approved. The validator rejects approvals without all of those safeguards. + +## Export approved profiles + +```sh +npm run social-proof:consent -- export-approved \ + --registry /private/path/bugdrop-consent.json \ + --output /private/path/bugdrop-approved-social-proof.json +``` + +This export contains only approved public profile fields. It omits account fingerprints, private +evidence references, contact status, and all unapproved apps. Review the output against the +permission evidence before copying it into the website repository. diff --git a/package.json b/package.json index fecad68..f687714 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "build:widget": "node scripts/build-widget.js", "deploy": "wrangler deploy", "render:board-gallery": "node scripts/render-board-gallery.mjs", + "social-proof:consent": "node scripts/social-proof-consent.mjs", "verify:legacy-compat": "node scripts/verify-legacy-compat.mjs", "release:plan": "node scripts/release/plan.mjs", "release:live": "node scripts/release/live-release.mjs", diff --git a/scripts/social-proof-consent-lib.mjs b/scripts/social-proof-consent-lib.mjs new file mode 100644 index 0000000..3af6ce7 --- /dev/null +++ b/scripts/social-proof-consent-lib.mjs @@ -0,0 +1,236 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +const STATUS_VALUES = new Set(['contacted', 'declined', 'approved', 'withdrawn']); + +export function validateRegistry(value) { + if (!isObject(value) || !hasExactKeys(value, ['schemaVersion', 'keyVerifier', 'entries'])) { + throw new Error('Invalid consent registry'); + } + if (value.schemaVersion !== 1 || !Array.isArray(value.entries)) { + throw new Error('Invalid consent registry'); + } + assertFingerprint(value.keyVerifier); + + const fingerprints = new Set(); + for (const entry of value.entries) { + validateRegistryEntry(entry); + if (fingerprints.has(entry.accountFingerprint)) { + throw new Error('Duplicate consent registry entry'); + } + fingerprints.add(entry.accountFingerprint); + } + return value; +} + +export function buildCandidateReview( + records, + registry, + excludedLogins, + fingerprintKey, + generatedAt = new Date().toISOString() +) { + validateRegistry(registry); + validateRegistryKey(registry, fingerprintKey); + assertIsoDate(generatedAt); + const excluded = new Set(validateExcludedLogins(excludedLogins)); + const decided = registry.entries.map(entry => Buffer.from(entry.accountFingerprint, 'hex')); + const eligible = records + .map(validateInstallationRecord) + .map(record => ({ + account: record.account, + installedAt: record.installedAt, + accountFingerprint: fingerprintAccount(record.account.login, fingerprintKey), + })) + .filter( + record => + !excluded.has(record.account.login.toLocaleLowerCase('en-US')) && + !decided.some(value => + timingSafeEqual(value, Buffer.from(record.accountFingerprint, 'hex')) + ) + ); + + const candidatesByAccount = new Map(); + for (const candidate of eligible) { + const existing = candidatesByAccount.get(candidate.accountFingerprint); + if (!existing || candidate.installedAt > existing.installedAt) { + candidatesByAccount.set(candidate.accountFingerprint, candidate); + } + } + const candidates = [...candidatesByAccount.values()].sort((a, b) => + a.installedAt.localeCompare(b.installedAt) + ); + + return { schemaVersion: 1, generatedAt, candidates }; +} + +export function buildApprovedExport(registry, generatedAt = new Date().toISOString()) { + validateRegistry(registry); + assertIsoDate(generatedAt); + const apps = registry.entries + .filter(entry => entry.status === 'approved') + .map(entry => ({ ...entry.approval.publicProfile })) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + return { schemaVersion: 1, generatedAt, apps }; +} + +export function fingerprintAccount(login, fingerprintKey) { + const normalized = normalizeGitHubLogin(login); + const key = validateFingerprintKey(fingerprintKey); + return createHmac('sha256', key).update(`account:${normalized}`, 'utf8').digest('hex'); +} + +export function createEmptyRegistry(fingerprintKey) { + return { + schemaVersion: 1, + keyVerifier: keyVerifier(fingerprintKey), + entries: [], + }; +} + +export function validateFingerprintKey(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(value)) { + throw new Error('Invalid social proof fingerprint key'); + } + const decoded = Buffer.from(value, 'base64url'); + if (decoded.length !== 32) throw new Error('Invalid social proof fingerprint key'); + return decoded; +} + +export function validateExcludedLogins(values) { + if (!Array.isArray(values) || values.length === 0) { + throw new Error('At least one owned or test account must be excluded'); + } + return values.map(normalizeGitHubLogin); +} + +export function validateRegistryKey(registry, fingerprintKey) { + validateRegistry(registry); + const actual = Buffer.from(registry.keyVerifier, 'hex'); + const expected = Buffer.from(keyVerifier(fingerprintKey), 'hex'); + if (!timingSafeEqual(actual, expected)) { + throw new Error('Consent registry does not match the account-fingerprint key'); + } +} + +function keyVerifier(fingerprintKey) { + const key = validateFingerprintKey(fingerprintKey); + return createHmac('sha256', key).update('registry-key-verifier:v1', 'utf8').digest('hex'); +} + +function validateRegistryEntry(entry) { + if (!isObject(entry) || !STATUS_VALUES.has(entry.status)) { + throw new Error('Invalid consent registry entry'); + } + assertFingerprint(entry.accountFingerprint); + assertIsoDate(entry.updatedAt); + + const expected = + entry.status === 'approved' + ? ['accountFingerprint', 'status', 'updatedAt', 'approval'] + : ['accountFingerprint', 'status', 'updatedAt']; + if (!hasExactKeys(entry, expected)) throw new Error('Invalid consent registry entry'); + if (entry.status === 'approved') validateApproval(entry.approval); +} + +function validateApproval(approval) { + if ( + !isObject(approval) || + !hasExactKeys(approval, [ + 'approvedAt', + 'authorizedRepresentativeConfirmed', + 'evidenceReference', + 'publicProfile', + ]) || + approval.authorizedRepresentativeConfirmed !== true || + typeof approval.evidenceReference !== 'string' || + approval.evidenceReference.trim() === '' + ) { + throw new Error('Invalid social proof approval'); + } + assertIsoDate(approval.approvedAt); + validatePublicProfile(approval.publicProfile); +} + +function validatePublicProfile(profile) { + if (!isObject(profile)) throw new Error('Invalid approved public profile'); + const allowed = ['displayName', 'url', 'logoUrl', 'quote', 'attribution']; + const keys = Object.keys(profile); + if ( + !keys.includes('displayName') || + !keys.includes('url') || + keys.some(key => !allowed.includes(key)) + ) { + throw new Error('Invalid approved public profile'); + } + for (const key of keys) { + if (typeof profile[key] !== 'string' || profile[key].trim() === '') { + throw new Error('Invalid approved public profile'); + } + } + assertHttpsUrl(profile.url); + if (profile.logoUrl) assertHttpsUrl(profile.logoUrl); +} + +function validateInstallationRecord(record) { + if ( + !isObject(record) || + !hasExactKeys(record, ['schemaVersion', 'installationId', 'account', 'installedAt']) || + record.schemaVersion !== 1 || + !isObject(record.account) || + !hasExactKeys(record.account, ['login', 'type', 'profileUrl']) || + typeof record.account.login !== 'string' || + !['User', 'Organization'].includes(record.account.type) + ) { + throw new Error('Invalid installation record'); + } + normalizeGitHubLogin(record.account.login); + assertPositiveInteger(record.installationId); + assertIsoDate(record.installedAt); + const expectedUrl = `https://github.com/${record.account.login}`; + if (![expectedUrl, `${expectedUrl}/`].includes(record.account.profileUrl)) { + throw new Error('Invalid installation record'); + } + return record; +} + +function normalizeGitHubLogin(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,98}[A-Za-z0-9])?$/.test(value)) { + throw new Error('Invalid GitHub account'); + } + return value.toLocaleLowerCase('en-US'); +} + +function hasExactKeys(value, expected) { + return Object.keys(value).sort().join('\0') === [...expected].sort().join('\0'); +} + +function isObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function assertFingerprint(value) { + if (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)) { + throw new Error('Invalid account fingerprint'); + } +} + +function assertPositiveInteger(value) { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error('Invalid installation ID'); +} + +function assertIsoDate(value) { + if ( + typeof value !== 'string' || + Number.isNaN(Date.parse(value)) || + new Date(value).toISOString() !== value + ) { + throw new Error('Invalid timestamp'); + } +} + +function assertHttpsUrl(value) { + const url = new URL(value); + if (url.protocol !== 'https:' || url.username || url.password) { + throw new Error('Invalid public URL'); + } +} diff --git a/scripts/social-proof-consent.mjs b/scripts/social-proof-consent.mjs new file mode 100644 index 0000000..95a23e4 --- /dev/null +++ b/scripts/social-proof-consent.mjs @@ -0,0 +1,209 @@ +import { execFile } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { lstat, readFile, realpath, stat, unlink, writeFile } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + buildApprovedExport, + buildCandidateReview, + createEmptyRegistry, + validateExcludedLogins, + validateFingerprintKey, + validateRegistry, + validateRegistryKey, +} from './social-proof-consent-lib.mjs'; + +const execFileAsync = promisify(execFile); +const INSTALLATION_PREFIX = 'installation:'; +const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +async function readInstallationRecords() { + const wrangler = resolve(REPOSITORY_ROOT, 'node_modules/.bin/wrangler'); + try { + const listed = await execFileAsync( + wrangler, + [ + 'kv', + 'key', + 'list', + '--binding', + 'INSTALLATION_ANALYTICS', + '--env', + 'production', + '--remote', + ], + { cwd: REPOSITORY_ROOT, maxBuffer: 10 * 1024 * 1024 } + ); + const keys = JSON.parse(listed.stdout); + if (!Array.isArray(keys)) throw new Error('invalid list'); + + const records = []; + for (const item of keys) { + if (!isObject(item) || typeof item.name !== 'string') throw new Error('invalid key'); + if (!item.name.startsWith(INSTALLATION_PREFIX)) continue; + const result = await execFileAsync( + wrangler, + [ + 'kv', + 'key', + 'get', + item.name, + '--binding', + 'INSTALLATION_ANALYTICS', + '--env', + 'production', + '--remote', + '--text', + ], + { cwd: REPOSITORY_ROOT, maxBuffer: 1024 * 1024 } + ); + records.push(JSON.parse(result.stdout)); + } + return records; + } catch { + throw new Error('Unable to read installation records from Cloudflare KV'); + } +} + +async function writePrivateFile(path, contents) { + await writeFile(await privateOutputPath(path), contents, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); +} + +async function initializePrivateFiles(registryPath, keyPath) { + const registry = await privateOutputPath(registryPath); + const key = await privateOutputPath(keyPath); + if (registry === key) throw new Error('Consent registry and fingerprint key paths must differ'); + await assertDoesNotExist(registry); + await assertDoesNotExist(key); + + const keyValue = randomBytes(32).toString('base64url'); + await writeFile(key, `${keyValue}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); + try { + await writeFile(registry, `${JSON.stringify(createEmptyRegistry(keyValue), null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); + } catch (error) { + await unlink(key); + throw error; + } +} + +async function assertDoesNotExist(path) { + try { + await lstat(path); + throw new Error('Private social proof file already exists'); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return; + throw error; + } +} + +async function writePrivateJson(path, value) { + await writePrivateFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +async function readRegistry(path) { + const target = await privateInputPath(path); + return validateRegistry(JSON.parse(await readFile(target, 'utf8'))); +} + +async function readFingerprintKey(path) { + const target = await privateInputPath(path); + const value = (await readFile(target, 'utf8')).trim(); + validateFingerprintKey(value); + return value; +} + +async function privateInputPath(path) { + const target = await realpath(resolve(path)); + await assertOutsideRepository(target); + const metadata = await stat(target); + if (!metadata.isFile() || (metadata.mode & 0o077) !== 0) { + throw new Error('Private social proof inputs must be owner-only regular files'); + } + return target; +} + +async function privateOutputPath(path) { + const parent = await realpath(dirname(resolve(path))); + const target = join(parent, basename(path)); + await assertOutsideRepository(target); + return target; +} + +async function assertOutsideRepository(path) { + const repository = await realpath(REPOSITORY_ROOT); + const relation = relative(repository, path); + if (relation === '' || (!relation.startsWith('..') && !isAbsolute(relation))) { + throw new Error('Private social proof files must be outside the repository'); + } +} + +function parseOptions(args) { + const options = {}; + for (let index = 0; index < args.length; index += 2) { + const name = args[index]; + const value = args[index + 1]; + if (!name?.startsWith('--') || !value) throw new Error('Invalid command options'); + options[name.slice(2)] = value; + } + return options; +} + +export async function runCli(args, dependencies = {}) { + const readRecords = dependencies.readInstallationRecords ?? readInstallationRecords; + const showReview = + dependencies.showReview ?? (review => console.log(JSON.stringify(review, null, 2))); + const [command, ...rawOptions] = args; + const options = parseOptions(rawOptions); + + if (command === 'init' && options.registry && options.key) { + await initializePrivateFiles(options.registry, options.key); + return 'Created a private consent registry and account-fingerprint key.'; + } + if (command === 'review' && options.registry && options.key && options.exclude) { + const excludedLogins = validateExcludedLogins( + options.exclude + .split(',') + .map(value => value.trim()) + .filter(Boolean) + ); + const registry = await readRegistry(options.registry); + const fingerprintKey = await readFingerprintKey(options.key); + validateRegistryKey(registry, fingerprintKey); + const records = await readRecords(); + const review = buildCandidateReview(records, registry, excludedLogins, fingerprintKey); + showReview(review); + return `Reviewed ${review.candidates.length} outreach candidate(s) without saving identities.`; + } + if (command === 'export-approved' && options.registry && options.output) { + const output = buildApprovedExport(await readRegistry(options.registry)); + await writePrivateJson(options.output, output); + return `Created an approved-only public export with ${output.apps.length} app(s).`; + } + throw new Error('Usage: init|review|export-approved with the documented private-file options'); +} + +function isObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNodeError(value) { + return value instanceof Error && 'code' in value; +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + runCli(process.argv.slice(2)) + .then(message => console.log(message)) + .catch(error => { + console.error(error instanceof Error ? error.message : 'Social proof workflow failed'); + process.exitCode = 1; + }); +} diff --git a/test/socialProofConsent.test.ts b/test/socialProofConsent.test.ts new file mode 100644 index 0000000..172e478 --- /dev/null +++ b/test/socialProofConsent.test.ts @@ -0,0 +1,291 @@ +import { chmod, mkdtemp, readFile, readdir, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + buildApprovedExport, + buildCandidateReview, + createEmptyRegistry, + fingerprintAccount, + validateRegistry, +} from '../scripts/social-proof-consent-lib.mjs'; +import { runCli } from '../scripts/social-proof-consent.mjs'; + +const installedAt = '2026-08-30T00:00:00.000Z'; +const generatedAt = '2026-08-30T01:00:00.000Z'; +const fingerprintKey = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const fingerprint = (login: string) => fingerprintAccount(login, fingerprintKey); +const registry = (entries: unknown[] = []) => ({ ...createEmptyRegistry(fingerprintKey), entries }); +const record = (installationId: number, login: string) => ({ + schemaVersion: 1, + installationId, + account: { login, type: 'Organization', profileUrl: `https://github.com/${login}` }, + installedAt, +}); + +describe('social proof consent workflow', () => { + it('creates owner-only registry and fingerprint key without overwriting files', async () => { + const directory = await mkdtemp(join(tmpdir(), 'bugdrop-consent-')); + const registry = join(directory, 'registry.json'); + const key = join(directory, 'fingerprint.key'); + + await expect(runCli(['init', '--registry', registry, '--key', key])).resolves.toContain( + 'fingerprint key' + ); + const keyValue = (await readFile(key, 'utf8')).trim(); + expect(keyValue).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(JSON.parse(await readFile(registry, 'utf8'))).toEqual(createEmptyRegistry(keyValue)); + expect((await stat(registry)).mode & 0o777).toBe(0o600); + expect((await stat(key)).mode & 0o777).toBe(0o600); + await expect(runCli(['init', '--registry', registry, '--key', key])).rejects.toThrow( + 'already exists' + ); + }); + + it('refuses private workflow files inside the repository from any cwd', async () => { + const original = process.cwd(); + process.chdir(join(original, 'scripts')); + try { + await expect( + runCli([ + 'init', + '--registry', + join(original, 'private-consent.json'), + '--key', + join(original, 'private-consent.key'), + ]) + ).rejects.toThrow('must be outside the repository'); + } finally { + process.chdir(original); + } + }); + + it('suppresses prior decisions by stable account fingerprint after reinstall', () => { + const decisions = registry([ + { + accountFingerprint: fingerprint('same-app'), + status: 'contacted', + updatedAt: generatedAt, + }, + ]); + + const review = buildCandidateReview( + [record(2, 'same-app'), record(3, 'new-app')], + decisions, + ['owned-account'], + fingerprintKey, + generatedAt + ); + expect(review.candidates).toEqual([ + { + account: record(3, 'new-app').account, + installedAt, + accountFingerprint: fingerprint('new-app'), + }, + ]); + }); + + it('deduplicates stale and reinstalled records by fingerprint and keeps the newest', () => { + const old = record(1, 'same-app'); + const current = { ...record(2, 'same-app'), installedAt: '2026-08-31T00:00:00.000Z' }; + + expect( + buildCandidateReview( + [current, old], + registry(), + ['owned-account'], + fingerprintKey, + generatedAt + ).candidates + ).toEqual([ + { + account: current.account, + installedAt: current.installedAt, + accountFingerprint: fingerprint('same-app'), + }, + ]); + }); + + it('requires and applies case-insensitive owned and test account exclusions', () => { + const records = [record(1, 'neonwatty'), record(2, 'Real-App')]; + const decisions = registry(); + + expect(() => buildCandidateReview(records, decisions, [], fingerprintKey, generatedAt)).toThrow( + 'must be excluded' + ); + expect( + buildCandidateReview(records, decisions, ['NEONWATTY'], fingerprintKey, generatedAt) + .candidates + ).toEqual([ + { + account: record(2, 'Real-App').account, + installedAt, + accountFingerprint: fingerprint('Real-App'), + }, + ]); + }); + + it('rejects a valid but mismatched fingerprint key', () => { + const otherKey = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; + expect(() => + buildCandidateReview( + [record(1, 'candidate-app')], + registry(), + ['owned-account'], + otherKey, + generatedAt + ) + ).toThrow('does not match'); + }); + + it('does not create an orphan key when initialization cannot create the registry', async () => { + const directory = await mkdtemp(join(tmpdir(), 'bugdrop-consent-atomic-')); + const registryPath = join(directory, 'registry.json'); + const key = join(directory, 'fingerprint.key'); + await runCli(['init', '--registry', registryPath, '--key', join(directory, 'first.key')]); + + await expect(runCli(['init', '--registry', registryPath, '--key', key])).rejects.toThrow( + 'already exists' + ); + await expect(readFile(key, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(runCli(['init', '--registry', key, '--key', key])).rejects.toThrow('must differ'); + await expect(readFile(key, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('reviews candidates without persisting their identities', async () => { + const directory = await mkdtemp(join(tmpdir(), 'bugdrop-consent-review-')); + const registry = join(directory, 'registry.json'); + const key = join(directory, 'fingerprint.key'); + await runCli(['init', '--registry', registry, '--key', key]); + const shown: unknown[] = []; + + await expect( + runCli(['review', '--registry', registry, '--key', key, '--exclude', 'owned-account'], { + readInstallationRecords: async () => [record(1, 'candidate-app')], + showReview: (value: unknown) => shown.push(value), + }) + ).resolves.toContain('without saving identities'); + expect(shown).toHaveLength(1); + expect(await readdir(directory)).toEqual(['fingerprint.key', 'registry.json']); + }); + + it('does not read production records before private inputs pass validation', async () => { + const directory = await mkdtemp(join(tmpdir(), 'bugdrop-consent-order-')); + let reads = 0; + + await expect( + runCli( + [ + 'review', + '--registry', + join(directory, 'missing-registry.json'), + '--key', + join(directory, 'missing.key'), + '--exclude', + 'owned-account', + ], + { + readInstallationRecords: async () => { + reads += 1; + return [record(1, 'candidate-app')]; + }, + } + ) + ).rejects.toMatchObject({ code: 'ENOENT' }); + expect(reads).toBe(0); + }); + + it('does not read production records before exclusions pass validation', async () => { + const directory = await mkdtemp(join(tmpdir(), 'bugdrop-consent-exclusions-')); + const registry = join(directory, 'registry.json'); + const key = join(directory, 'fingerprint.key'); + await runCli(['init', '--registry', registry, '--key', key]); + let reads = 0; + const dependencies = { + readInstallationRecords: async () => { + reads += 1; + return [record(1, 'candidate-app')]; + }, + }; + + for (const exclude of [', ,', 'not a github login']) { + await expect( + runCli(['review', '--registry', registry, '--key', key, '--exclude', exclude], dependencies) + ).rejects.toThrow(); + } + expect(reads).toBe(0); + }); + + it('refuses a fingerprint key that is readable by other users', async () => { + const directory = await mkdtemp(join(tmpdir(), 'bugdrop-consent-permissions-')); + const registry = join(directory, 'registry.json'); + const key = join(directory, 'fingerprint.key'); + await runCli(['init', '--registry', registry, '--key', key]); + await chmod(key, 0o644); + + await expect( + runCli(['review', '--registry', registry, '--key', key, '--exclude', 'owned-account'], { + readInstallationRecords: async () => [], + }) + ).rejects.toThrow('owner-only regular files'); + }); + + it('exports only explicitly approved public fields without private fingerprints', () => { + const decisions = registry([ + { accountFingerprint: fingerprint('declined'), status: 'declined', updatedAt: generatedAt }, + { + accountFingerprint: fingerprint('approved'), + status: 'approved', + updatedAt: generatedAt, + approval: { + approvedAt: generatedAt, + authorizedRepresentativeConfirmed: true, + evidenceReference: 'private/email/2026-08-30', + publicProfile: { + displayName: 'Example App', + url: 'https://example.com', + quote: 'BugDrop keeps feedback close to the work.', + attribution: 'Example App team', + }, + }, + }, + ]); + + const output = buildApprovedExport(decisions, generatedAt); + expect(output).toEqual({ + schemaVersion: 1, + generatedAt, + apps: [decisions.entries[1].approval?.publicProfile], + }); + expect(JSON.stringify(output)).not.toContain('accountFingerprint'); + expect(JSON.stringify(output)).not.toContain('evidenceReference'); + }); + + it('rejects approval without authority confirmation or with extra private fields', () => { + const base = registry([ + { + accountFingerprint: fingerprint('example'), + status: 'approved', + updatedAt: generatedAt, + approval: { + approvedAt: generatedAt, + authorizedRepresentativeConfirmed: false, + evidenceReference: 'private/email/2026-08-30', + publicProfile: { displayName: 'Example App', url: 'https://example.com' }, + }, + }, + ]); + expect(() => validateRegistry(base)).toThrow('Invalid social proof approval'); + + base.entries[0].approval.authorizedRepresentativeConfirmed = true; + Object.assign(base.entries[0].approval.publicProfile, { installationId: 'private' }); + expect(() => validateRegistry(base)).toThrow('Invalid approved public profile'); + }); + + it('rejects malformed installation records instead of displaying them', () => { + const malformed = { ...record(1, 'example'), repository: 'secret/repo' }; + expect(() => + buildCandidateReview([malformed], registry(), ['owned-account'], fingerprintKey, generatedAt) + ).toThrow('Invalid installation record'); + }); +});