From 945b5e66b72f6f502f83432343345674bbcf239c Mon Sep 17 00:00:00 2001 From: neonwatty Date: Sun, 30 Aug 2026 09:42:33 -0700 Subject: [PATCH 1/5] feat: add consent-gated social proof workflow --- docs/social-proof-consent.md | 69 ++++++++ package.json | 1 + scripts/social-proof-consent.mjs | 295 +++++++++++++++++++++++++++++++ test/socialProofConsent.test.ts | 142 +++++++++++++++ 4 files changed, 507 insertions(+) create mode 100644 docs/social-proof-consent.md create mode 100644 scripts/social-proof-consent.mjs create mode 100644 test/socialProofConsent.test.ts diff --git a/docs/social-proof-consent.md b/docs/social-proof-consent.md new file mode 100644 index 0000000..e793797 --- /dev/null +++ b/docs/social-proof-consent.md @@ -0,0 +1,69 @@ +# Consent-based social proof workflow + +This operator-only workflow turns the already-approved minimal installation records into a +private outreach list. 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 candidate report 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. +- Never commit the registry, candidate report, 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 regenerating the candidate report. +- 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 + +```sh +npm run social-proof:consent -- init --output /private/path/bugdrop-consent.json +``` + +## Prepare an outreach list + +Authenticate Wrangler with read access to the production Cloudflare KV namespace, then run: + +```sh +npm run social-proof:consent -- prepare \ + --registry /private/path/bugdrop-consent.json \ + --exclude mean-weasel,neonwatty \ + --output /private/path/bugdrop-outreach.json +``` + +The terminal prints only the aggregate candidate count. Identifying values appear only in the +owner-only output file. 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 `installationId`, `status`, and `updatedAt`. For example: + +```json +{ + "installationId": 123, + "status": "declined", + "updatedAt": "2026-08-30T00:00:00.000Z" +} +``` + +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 installation IDs, 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.mjs b/scripts/social-proof-consent.mjs new file mode 100644 index 0000000..1d93184 --- /dev/null +++ b/scripts/social-proof-consent.mjs @@ -0,0 +1,295 @@ +import { execFile } from 'node:child_process'; +import { readFile, realpath, writeFile } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { pathToFileURL } from 'node:url'; + +const execFileAsync = promisify(execFile); +const INSTALLATION_PREFIX = 'installation:'; +const STATUS_VALUES = new Set(['contacted', 'declined', 'approved', 'withdrawn']); + +export function validateRegistry(value) { + if (!isObject(value) || !hasExactKeys(value, ['schemaVersion', 'entries'])) { + throw new Error('Invalid consent registry'); + } + if (value.schemaVersion !== 1 || !Array.isArray(value.entries)) { + throw new Error('Invalid consent registry'); + } + + const ids = new Set(); + for (const entry of value.entries) { + validateRegistryEntry(entry); + if (ids.has(entry.installationId)) throw new Error('Duplicate consent registry entry'); + ids.add(entry.installationId); + } + return value; +} + +export function buildCandidateReport( + records, + registry, + excludedLogins, + generatedAt = new Date().toISOString() +) { + validateRegistry(registry); + assertIsoDate(generatedAt); + if (!Array.isArray(excludedLogins) || excludedLogins.length === 0) { + throw new Error('At least one owned or test account must be excluded'); + } + const excluded = new Set(excludedLogins.map(normalizeExcludedLogin)); + const decided = new Set(registry.entries.map(entry => entry.installationId)); + const candidates = records + .map(validateInstallationRecord) + .filter( + record => + !decided.has(record.installationId) && + !excluded.has(record.account.login.toLocaleLowerCase('en-US')) + ) + .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 }; +} + +async function readInstallationRecords() { + const wrangler = resolve('node_modules/.bin/wrangler'); + try { + const listed = await execFileAsync( + wrangler, + [ + 'kv', + 'key', + 'list', + '--binding', + 'INSTALLATION_ANALYTICS', + '--env', + 'production', + '--remote', + ], + { 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', + ], + { 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 writePrivateJson(path, value) { + await writeFile(await privateOutputPath(path), `${JSON.stringify(value, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); +} + +async function readRegistry(path) { + const target = await realpath(resolve(path)); + await assertOutsideRepository(target); + return validateRegistry(JSON.parse(await readFile(target, 'utf8'))); +} + +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(process.cwd()); + const relation = relative(repository, path); + if (relation === '' || (!relation.startsWith('..') && !isAbsolute(relation))) { + throw new Error('Private social proof files must be outside the repository'); + } +} + +function validateRegistryEntry(entry) { + if (!isObject(entry) || !STATUS_VALUES.has(entry.status)) { + throw new Error('Invalid consent registry entry'); + } + assertPositiveInteger(entry.installationId); + assertIsoDate(entry.updatedAt); + + const expected = + entry.status === 'approved' + ? ['installationId', 'status', 'updatedAt', 'approval'] + : ['installationId', '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'); + } + } + assertHttpUrl(profile.url); + if (profile.logoUrl) assertHttpUrl(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' || + !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,98}[A-Za-z0-9])?$/.test(record.account.login) || + !['User', 'Organization'].includes(record.account.type) + ) { + throw new Error('Invalid installation record'); + } + 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 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 assertPositiveInteger(value) { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error('Invalid installation ID'); +} + +function assertIsoDate(value) { + if (typeof value !== 'string' || new Date(value).toISOString() !== value) { + throw new Error('Invalid timestamp'); + } +} + +function assertHttpUrl(value) { + const url = new URL(value); + if (url.protocol !== 'https:' || url.username || url.password) { + throw new Error('Invalid public URL'); + } +} + +function normalizeExcludedLogin(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 excluded account'); + } + return value.toLocaleLowerCase('en-US'); +} + +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) { + const [command, ...rawOptions] = args; + const options = parseOptions(rawOptions); + if (command === 'init' && options.output) { + await writePrivateJson(options.output, { schemaVersion: 1, entries: [] }); + return 'Created an empty private consent registry.'; + } + if (command === 'prepare' && options.registry && options.output) { + const report = buildCandidateReport( + await readInstallationRecords(), + await readRegistry(options.registry), + options.exclude + ?.split(',') + .map(value => value.trim()) + .filter(Boolean) ?? [] + ); + await writePrivateJson(options.output, report); + return `Created a private outreach report with ${report.candidates.length} candidate(s).`; + } + 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|prepare|export-approved with --registry, --output, and prepare --exclude' + ); +} + +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..44c6e9e --- /dev/null +++ b/test/socialProofConsent.test.ts @@ -0,0 +1,142 @@ +import { mkdtemp, readFile, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + buildApprovedExport, + buildCandidateReport, + runCli, + validateRegistry, +} from '../scripts/social-proof-consent.mjs'; + +const installedAt = '2026-08-30T00:00:00.000Z'; +const generatedAt = '2026-08-30T01:00:00.000Z'; +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 an owner-only empty registry without overwriting files', async () => { + const directory = await mkdtemp(join(tmpdir(), 'bugdrop-consent-')); + const output = join(directory, 'registry.json'); + + await expect(runCli(['init', '--output', output])).resolves.toContain('empty private'); + expect(JSON.parse(await readFile(output, 'utf8'))).toEqual({ schemaVersion: 1, entries: [] }); + expect((await stat(output)).mode & 0o777).toBe(0o600); + await expect(runCli(['init', '--output', output])).rejects.toThrow('EEXIST'); + }); + + it('refuses to create private workflow files inside the repository', async () => { + await expect( + runCli(['init', '--output', join(process.cwd(), 'private-consent.json')]) + ).rejects.toThrow('must be outside the repository'); + }); + + it('excludes every installation with an existing outreach decision', () => { + const registry = { + schemaVersion: 1, + entries: [ + { installationId: 2, status: 'contacted', updatedAt: generatedAt }, + { installationId: 3, status: 'declined', updatedAt: generatedAt }, + ], + }; + + expect( + buildCandidateReport( + [record(3, 'third'), record(1, 'first'), record(2, 'second')], + registry, + ['owned-account'], + generatedAt + ) + ).toEqual({ + schemaVersion: 1, + generatedAt, + candidates: [record(1, 'first')], + }); + }); + + it('requires and applies case-insensitive owned and test account exclusions', () => { + const records = [record(1, 'neonwatty'), record(2, 'Real-App')]; + const registry = { schemaVersion: 1, entries: [] }; + + expect(() => buildCandidateReport(records, registry, [], generatedAt)).toThrow( + 'must be excluded' + ); + expect(buildCandidateReport(records, registry, ['NEONWATTY'], generatedAt).candidates).toEqual([ + record(2, 'Real-App'), + ]); + }); + + it('exports only explicitly approved public fields without private identifiers', () => { + const registry = { + schemaVersion: 1, + entries: [ + { installationId: 1, status: 'declined', updatedAt: generatedAt }, + { + installationId: 2, + 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(registry, generatedAt); + expect(output).toEqual({ + schemaVersion: 1, + generatedAt, + apps: [registry.entries[1].approval?.publicProfile], + }); + expect(JSON.stringify(output)).not.toContain('installationId'); + expect(JSON.stringify(output)).not.toContain('evidenceReference'); + }); + + it('rejects approval without authority confirmation or with extra private fields', () => { + const base = { + schemaVersion: 1, + entries: [ + { + installationId: 2, + 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 copying them to outreach output', () => { + const malformed = { ...record(1, 'example'), repository: 'secret/repo' }; + expect(() => + buildCandidateReport( + [malformed], + { schemaVersion: 1, entries: [] }, + ['owned-account'], + generatedAt + ) + ).toThrow('Invalid installation record'); + }); +}); From 189ad4dd82523d23011dc199189613eaf6845258 Mon Sep 17 00:00:00 2001 From: neonwatty Date: Sun, 30 Aug 2026 10:09:01 -0700 Subject: [PATCH 2/5] fix: avoid retaining outreach identities --- docs/social-proof-consent.md | 55 +++--- scripts/social-proof-consent-lib.mjs | 197 +++++++++++++++++++++ scripts/social-proof-consent.mjs | 244 +++++++-------------------- test/socialProofConsent.test.ts | 136 ++++++++++----- 4 files changed, 388 insertions(+), 244 deletions(-) create mode 100644 scripts/social-proof-consent-lib.mjs diff --git a/docs/social-proof-consent.md b/docs/social-proof-consent.md index e793797..a74685a 100644 --- a/docs/social-proof-consent.md +++ b/docs/social-proof-consent.md @@ -1,57 +1,72 @@ # Consent-based social proof workflow -This operator-only workflow turns the already-approved minimal installation records into a -private outreach list. It never sends messages, exposes a public endpoint, or authorizes public -display from an installation alone. +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 candidate report 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. -- Never commit the registry, candidate report, permission evidence, or installation identities. +- 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 regenerating the candidate report. + 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 --output /private/path/bugdrop-consent.json +npm run social-proof:consent -- init \ + --registry /private/path/bugdrop-consent.json \ + --key /private/path/bugdrop-social-proof.key ``` -## Prepare an outreach list +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. + +## Review outreach candidates Authenticate Wrangler with read access to the production Cloudflare KV namespace, then run: ```sh -npm run social-proof:consent -- prepare \ +npm run social-proof:consent -- review \ --registry /private/path/bugdrop-consent.json \ - --exclude mean-weasel,neonwatty \ - --output /private/path/bugdrop-outreach.json + --key /private/path/bugdrop-social-proof.key \ + --exclude mean-weasel,neonwatty ``` -The terminal prints only the aggregate candidate count. Identifying values appear only in the -owner-only output file. 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. +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 `installationId`, `status`, and `updatedAt`. For example: +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 { - "installationId": 123, + "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. @@ -64,6 +79,6 @@ npm run social-proof:consent -- export-approved \ --output /private/path/bugdrop-approved-social-proof.json ``` -This export contains only approved public profile fields. It omits installation IDs, private +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/scripts/social-proof-consent-lib.mjs b/scripts/social-proof-consent-lib.mjs new file mode 100644 index 0000000..34ceee4 --- /dev/null +++ b/scripts/social-proof-consent-lib.mjs @@ -0,0 +1,197 @@ +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', 'entries'])) { + throw new Error('Invalid consent registry'); + } + if (value.schemaVersion !== 1 || !Array.isArray(value.entries)) { + throw new Error('Invalid consent registry'); + } + + 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); + assertIsoDate(generatedAt); + if (!Array.isArray(excludedLogins) || excludedLogins.length === 0) { + throw new Error('At least one owned or test account must be excluded'); + } + const excluded = new Set(excludedLogins.map(normalizeGitHubLogin)); + const decided = registry.entries.map(entry => Buffer.from(entry.accountFingerprint, 'hex')); + const candidates = records + .map(validateInstallationRecord) + .map(record => ({ + ...record, + 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')) + ) + ) + .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(normalized, 'utf8').digest('hex'); +} + +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; +} + +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 index 1d93184..983eb0b 100644 --- a/scripts/social-proof-consent.mjs +++ b/scripts/social-proof-consent.mjs @@ -1,67 +1,22 @@ import { execFile } from 'node:child_process'; -import { readFile, realpath, writeFile } from 'node:fs/promises'; +import { randomBytes } from 'node:crypto'; +import { readFile, realpath, stat, writeFile } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { promisify } from 'node:util'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + buildApprovedExport, + buildCandidateReview, + validateFingerprintKey, + validateRegistry, +} from './social-proof-consent-lib.mjs'; const execFileAsync = promisify(execFile); const INSTALLATION_PREFIX = 'installation:'; -const STATUS_VALUES = new Set(['contacted', 'declined', 'approved', 'withdrawn']); - -export function validateRegistry(value) { - if (!isObject(value) || !hasExactKeys(value, ['schemaVersion', 'entries'])) { - throw new Error('Invalid consent registry'); - } - if (value.schemaVersion !== 1 || !Array.isArray(value.entries)) { - throw new Error('Invalid consent registry'); - } - - const ids = new Set(); - for (const entry of value.entries) { - validateRegistryEntry(entry); - if (ids.has(entry.installationId)) throw new Error('Duplicate consent registry entry'); - ids.add(entry.installationId); - } - return value; -} - -export function buildCandidateReport( - records, - registry, - excludedLogins, - generatedAt = new Date().toISOString() -) { - validateRegistry(registry); - assertIsoDate(generatedAt); - if (!Array.isArray(excludedLogins) || excludedLogins.length === 0) { - throw new Error('At least one owned or test account must be excluded'); - } - const excluded = new Set(excludedLogins.map(normalizeExcludedLogin)); - const decided = new Set(registry.entries.map(entry => entry.installationId)); - const candidates = records - .map(validateInstallationRecord) - .filter( - record => - !decided.has(record.installationId) && - !excluded.has(record.account.login.toLocaleLowerCase('en-US')) - ) - .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 }; -} +const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); async function readInstallationRecords() { - const wrangler = resolve('node_modules/.bin/wrangler'); + const wrangler = resolve(REPOSITORY_ROOT, 'node_modules/.bin/wrangler'); try { const listed = await execFileAsync( wrangler, @@ -75,7 +30,7 @@ async function readInstallationRecords() { 'production', '--remote', ], - { maxBuffer: 10 * 1024 * 1024 } + { cwd: REPOSITORY_ROOT, maxBuffer: 10 * 1024 * 1024 } ); const keys = JSON.parse(listed.stdout); if (!Array.isArray(keys)) throw new Error('invalid list'); @@ -98,7 +53,7 @@ async function readInstallationRecords() { '--remote', '--text', ], - { maxBuffer: 1024 * 1024 } + { cwd: REPOSITORY_ROOT, maxBuffer: 1024 * 1024 } ); records.push(JSON.parse(result.stdout)); } @@ -108,18 +63,38 @@ async function readInstallationRecords() { } } -async function writePrivateJson(path, value) { - await writeFile(await privateOutputPath(path), `${JSON.stringify(value, null, 2)}\n`, { +async function writePrivateFile(path, contents) { + await writeFile(await privateOutputPath(path), contents, { encoding: 'utf8', flag: 'wx', mode: 0o600, }); } +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); - return validateRegistry(JSON.parse(await readFile(target, 'utf8'))); + 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) { @@ -130,121 +105,13 @@ async function privateOutputPath(path) { } async function assertOutsideRepository(path) { - const repository = await realpath(process.cwd()); + 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 validateRegistryEntry(entry) { - if (!isObject(entry) || !STATUS_VALUES.has(entry.status)) { - throw new Error('Invalid consent registry entry'); - } - assertPositiveInteger(entry.installationId); - assertIsoDate(entry.updatedAt); - - const expected = - entry.status === 'approved' - ? ['installationId', 'status', 'updatedAt', 'approval'] - : ['installationId', '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'); - } - } - assertHttpUrl(profile.url); - if (profile.logoUrl) assertHttpUrl(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' || - !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,98}[A-Za-z0-9])?$/.test(record.account.login) || - !['User', 'Organization'].includes(record.account.type) - ) { - throw new Error('Invalid installation record'); - } - 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 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 assertPositiveInteger(value) { - if (!Number.isSafeInteger(value) || value <= 0) throw new Error('Invalid installation ID'); -} - -function assertIsoDate(value) { - if (typeof value !== 'string' || new Date(value).toISOString() !== value) { - throw new Error('Invalid timestamp'); - } -} - -function assertHttpUrl(value) { - const url = new URL(value); - if (url.protocol !== 'https:' || url.username || url.password) { - throw new Error('Invalid public URL'); - } -} - -function normalizeExcludedLogin(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 excluded account'); - } - return value.toLocaleLowerCase('en-US'); -} - function parseOptions(args) { const options = {}; for (let index = 0; index < args.length; index += 2) { @@ -256,33 +123,42 @@ function parseOptions(args) { return options; } -export async function runCli(args) { +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.output) { - await writePrivateJson(options.output, { schemaVersion: 1, entries: [] }); - return 'Created an empty private consent registry.'; + + if (command === 'init' && options.registry && options.key) { + const key = randomBytes(32).toString('base64url'); + await writePrivateFile(options.key, `${key}\n`); + await writePrivateJson(options.registry, { schemaVersion: 1, entries: [] }); + return 'Created a private consent registry and account-fingerprint key.'; } - if (command === 'prepare' && options.registry && options.output) { - const report = buildCandidateReport( - await readInstallationRecords(), + if (command === 'review' && options.registry && options.key && options.exclude) { + const review = buildCandidateReview( + await readRecords(), await readRegistry(options.registry), options.exclude - ?.split(',') + .split(',') .map(value => value.trim()) - .filter(Boolean) ?? [] + .filter(Boolean), + await readFingerprintKey(options.key) ); - await writePrivateJson(options.output, report); - return `Created a private outreach report with ${report.candidates.length} candidate(s).`; + 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|prepare|export-approved with --registry, --output, and prepare --exclude' - ); + 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); } if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { diff --git a/test/socialProofConsent.test.ts b/test/socialProofConsent.test.ts index 44c6e9e..6005cba 100644 --- a/test/socialProofConsent.test.ts +++ b/test/socialProofConsent.test.ts @@ -1,16 +1,19 @@ -import { mkdtemp, readFile, stat } from 'node:fs/promises'; +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, - buildCandidateReport, - runCli, + buildCandidateReview, + fingerprintAccount, validateRegistry, -} from '../scripts/social-proof-consent.mjs'; +} 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 record = (installationId: number, login: string) => ({ schemaVersion: 1, installationId, @@ -19,64 +22,116 @@ const record = (installationId: number, login: string) => ({ }); describe('social proof consent workflow', () => { - it('creates an owner-only empty registry without overwriting files', async () => { + it('creates owner-only registry and fingerprint key without overwriting files', async () => { const directory = await mkdtemp(join(tmpdir(), 'bugdrop-consent-')); - const output = join(directory, 'registry.json'); + const registry = join(directory, 'registry.json'); + const key = join(directory, 'fingerprint.key'); - await expect(runCli(['init', '--output', output])).resolves.toContain('empty private'); - expect(JSON.parse(await readFile(output, 'utf8'))).toEqual({ schemaVersion: 1, entries: [] }); - expect((await stat(output)).mode & 0o777).toBe(0o600); - await expect(runCli(['init', '--output', output])).rejects.toThrow('EEXIST'); + await expect(runCli(['init', '--registry', registry, '--key', key])).resolves.toContain( + 'fingerprint key' + ); + expect(JSON.parse(await readFile(registry, 'utf8'))).toEqual({ + schemaVersion: 1, + entries: [], + }); + expect((await readFile(key, 'utf8')).trim()).toMatch(/^[A-Za-z0-9_-]{43}$/); + 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('EEXIST'); }); - it('refuses to create private workflow files inside the repository', async () => { - await expect( - runCli(['init', '--output', join(process.cwd(), 'private-consent.json')]) - ).rejects.toThrow('must be outside the repository'); + 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('excludes every installation with an existing outreach decision', () => { + it('suppresses prior decisions by stable account fingerprint after reinstall', () => { const registry = { schemaVersion: 1, entries: [ - { installationId: 2, status: 'contacted', updatedAt: generatedAt }, - { installationId: 3, status: 'declined', updatedAt: generatedAt }, + { + accountFingerprint: fingerprint('same-app'), + status: 'contacted', + updatedAt: generatedAt, + }, ], }; - expect( - buildCandidateReport( - [record(3, 'third'), record(1, 'first'), record(2, 'second')], - registry, - ['owned-account'], - generatedAt - ) - ).toEqual({ - schemaVersion: 1, - generatedAt, - candidates: [record(1, 'first')], - }); + const review = buildCandidateReview( + [record(2, 'same-app'), record(3, 'new-app')], + registry, + ['owned-account'], + fingerprintKey, + generatedAt + ); + expect(review.candidates).toEqual([ + { ...record(3, 'new-app'), accountFingerprint: fingerprint('new-app') }, + ]); }); it('requires and applies case-insensitive owned and test account exclusions', () => { const records = [record(1, 'neonwatty'), record(2, 'Real-App')]; const registry = { schemaVersion: 1, entries: [] }; - expect(() => buildCandidateReport(records, registry, [], generatedAt)).toThrow( + expect(() => buildCandidateReview(records, registry, [], fingerprintKey, generatedAt)).toThrow( 'must be excluded' ); - expect(buildCandidateReport(records, registry, ['NEONWATTY'], generatedAt).candidates).toEqual([ - record(2, 'Real-App'), - ]); + expect( + buildCandidateReview(records, registry, ['NEONWATTY'], fingerprintKey, generatedAt).candidates + ).toEqual([{ ...record(2, 'Real-App'), accountFingerprint: fingerprint('Real-App') }]); + }); + + 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('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 identifiers', () => { + it('exports only explicitly approved public fields without private fingerprints', () => { const registry = { schemaVersion: 1, entries: [ - { installationId: 1, status: 'declined', updatedAt: generatedAt }, + { accountFingerprint: fingerprint('declined'), status: 'declined', updatedAt: generatedAt }, { - installationId: 2, + accountFingerprint: fingerprint('approved'), status: 'approved', updatedAt: generatedAt, approval: { @@ -100,7 +155,7 @@ describe('social proof consent workflow', () => { generatedAt, apps: [registry.entries[1].approval?.publicProfile], }); - expect(JSON.stringify(output)).not.toContain('installationId'); + expect(JSON.stringify(output)).not.toContain('accountFingerprint'); expect(JSON.stringify(output)).not.toContain('evidenceReference'); }); @@ -109,7 +164,7 @@ describe('social proof consent workflow', () => { schemaVersion: 1, entries: [ { - installationId: 2, + accountFingerprint: fingerprint('example'), status: 'approved', updatedAt: generatedAt, approval: { @@ -128,13 +183,14 @@ describe('social proof consent workflow', () => { expect(() => validateRegistry(base)).toThrow('Invalid approved public profile'); }); - it('rejects malformed installation records instead of copying them to outreach output', () => { + it('rejects malformed installation records instead of displaying them', () => { const malformed = { ...record(1, 'example'), repository: 'secret/repo' }; expect(() => - buildCandidateReport( + buildCandidateReview( [malformed], { schemaVersion: 1, entries: [] }, ['owned-account'], + fingerprintKey, generatedAt ) ).toThrow('Invalid installation record'); From 5a8144b0752d26f18cbc1038ca67786a52c52f39 Mon Sep 17 00:00:00 2001 From: neonwatty Date: Sun, 30 Aug 2026 10:16:00 -0700 Subject: [PATCH 3/5] fix: harden consent registry safeguards --- docs/social-proof-consent.md | 3 +- scripts/social-proof-consent-lib.mjs | 46 ++++++- scripts/social-proof-consent.mjs | 42 ++++++- test/socialProofConsent.test.ts | 178 +++++++++++++++++---------- 4 files changed, 192 insertions(+), 77 deletions(-) diff --git a/docs/social-proof-consent.md b/docs/social-proof-consent.md index a74685a..acb87ed 100644 --- a/docs/social-proof-consent.md +++ b/docs/social-proof-consent.md @@ -31,7 +31,8 @@ npm run social-proof:consent -- init \ 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. +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 diff --git a/scripts/social-proof-consent-lib.mjs b/scripts/social-proof-consent-lib.mjs index 34ceee4..7d75ffb 100644 --- a/scripts/social-proof-consent-lib.mjs +++ b/scripts/social-proof-consent-lib.mjs @@ -3,12 +3,13 @@ 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', 'entries'])) { + 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) { @@ -29,16 +30,18 @@ export function buildCandidateReview( generatedAt = new Date().toISOString() ) { validateRegistry(registry); + assertRegistryKey(registry, fingerprintKey); assertIsoDate(generatedAt); if (!Array.isArray(excludedLogins) || excludedLogins.length === 0) { throw new Error('At least one owned or test account must be excluded'); } const excluded = new Set(excludedLogins.map(normalizeGitHubLogin)); const decided = registry.entries.map(entry => Buffer.from(entry.accountFingerprint, 'hex')); - const candidates = records + const eligible = records .map(validateInstallationRecord) .map(record => ({ - ...record, + account: record.account, + installedAt: record.installedAt, accountFingerprint: fingerprintAccount(record.account.login, fingerprintKey), })) .filter( @@ -47,8 +50,18 @@ export function buildCandidateReview( !decided.some(value => timingSafeEqual(value, Buffer.from(record.accountFingerprint, 'hex')) ) - ) - .sort((a, b) => a.installedAt.localeCompare(b.installedAt)); + ); + + 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 }; } @@ -66,7 +79,15 @@ export function buildApprovedExport(registry, generatedAt = new Date().toISOStri export function fingerprintAccount(login, fingerprintKey) { const normalized = normalizeGitHubLogin(login); const key = validateFingerprintKey(fingerprintKey); - return createHmac('sha256', key).update(normalized, 'utf8').digest('hex'); + 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) { @@ -78,6 +99,19 @@ export function validateFingerprintKey(value) { return decoded; } +function assertRegistryKey(registry, fingerprintKey) { + 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'); diff --git a/scripts/social-proof-consent.mjs b/scripts/social-proof-consent.mjs index 983eb0b..0dd4880 100644 --- a/scripts/social-proof-consent.mjs +++ b/scripts/social-proof-consent.mjs @@ -1,12 +1,13 @@ import { execFile } from 'node:child_process'; import { randomBytes } from 'node:crypto'; -import { readFile, realpath, stat, writeFile } from 'node:fs/promises'; +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, validateFingerprintKey, validateRegistry, } from './social-proof-consent-lib.mjs'; @@ -71,6 +72,37 @@ async function writePrivateFile(path, contents) { }); } +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`); } @@ -131,9 +163,7 @@ export async function runCli(args, dependencies = {}) { const options = parseOptions(rawOptions); if (command === 'init' && options.registry && options.key) { - const key = randomBytes(32).toString('base64url'); - await writePrivateFile(options.key, `${key}\n`); - await writePrivateJson(options.registry, { schemaVersion: 1, entries: [] }); + 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) { @@ -161,6 +191,10 @@ 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)) diff --git a/test/socialProofConsent.test.ts b/test/socialProofConsent.test.ts index 6005cba..2e5066f 100644 --- a/test/socialProofConsent.test.ts +++ b/test/socialProofConsent.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import { buildApprovedExport, buildCandidateReview, + createEmptyRegistry, fingerprintAccount, validateRegistry, } from '../scripts/social-proof-consent-lib.mjs'; @@ -14,6 +15,7 @@ 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, @@ -30,14 +32,14 @@ describe('social proof consent workflow', () => { await expect(runCli(['init', '--registry', registry, '--key', key])).resolves.toContain( 'fingerprint key' ); - expect(JSON.parse(await readFile(registry, 'utf8'))).toEqual({ - schemaVersion: 1, - entries: [], - }); - expect((await readFile(key, 'utf8')).trim()).toMatch(/^[A-Za-z0-9_-]{43}$/); + 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('EEXIST'); + await expect(runCli(['init', '--registry', registry, '--key', key])).rejects.toThrow( + 'already exists' + ); }); it('refuses private workflow files inside the repository from any cwd', async () => { @@ -59,39 +61,95 @@ describe('social proof consent workflow', () => { }); it('suppresses prior decisions by stable account fingerprint after reinstall', () => { - const registry = { - schemaVersion: 1, - entries: [ - { - accountFingerprint: fingerprint('same-app'), - status: 'contacted', - updatedAt: generatedAt, - }, - ], - }; + const decisions = registry([ + { + accountFingerprint: fingerprint('same-app'), + status: 'contacted', + updatedAt: generatedAt, + }, + ]); const review = buildCandidateReview( [record(2, 'same-app'), record(3, 'new-app')], - registry, + decisions, ['owned-account'], fingerprintKey, generatedAt ); expect(review.candidates).toEqual([ - { ...record(3, 'new-app'), accountFingerprint: fingerprint('new-app') }, + { + 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 registry = { schemaVersion: 1, entries: [] }; + const decisions = registry(); - expect(() => buildCandidateReview(records, registry, [], fingerprintKey, generatedAt)).toThrow( + expect(() => buildCandidateReview(records, decisions, [], fingerprintKey, generatedAt)).toThrow( 'must be excluded' ); expect( - buildCandidateReview(records, registry, ['NEONWATTY'], fingerprintKey, generatedAt).candidates - ).toEqual([{ ...record(2, 'Real-App'), accountFingerprint: fingerprint('Real-App') }]); + 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 () => { @@ -126,56 +184,50 @@ describe('social proof consent workflow', () => { }); it('exports only explicitly approved public fields without private fingerprints', () => { - const registry = { - schemaVersion: 1, - entries: [ - { 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 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(registry, generatedAt); + const output = buildApprovedExport(decisions, generatedAt); expect(output).toEqual({ schemaVersion: 1, generatedAt, - apps: [registry.entries[1].approval?.publicProfile], + 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 = { - schemaVersion: 1, - entries: [ - { - 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' }, - }, + 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; @@ -186,13 +238,7 @@ describe('social proof consent workflow', () => { it('rejects malformed installation records instead of displaying them', () => { const malformed = { ...record(1, 'example'), repository: 'secret/repo' }; expect(() => - buildCandidateReview( - [malformed], - { schemaVersion: 1, entries: [] }, - ['owned-account'], - fingerprintKey, - generatedAt - ) + buildCandidateReview([malformed], registry(), ['owned-account'], fingerprintKey, generatedAt) ).toThrow('Invalid installation record'); }); }); From 97c990aa6efefe76b7611db96e3ce1d2cae911c0 Mon Sep 17 00:00:00 2001 From: neonwatty Date: Sun, 30 Aug 2026 10:19:12 -0700 Subject: [PATCH 4/5] fix: validate consent inputs before remote reads --- scripts/social-proof-consent-lib.mjs | 5 +++-- scripts/social-proof-consent.mjs | 11 ++++++++--- test/socialProofConsent.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/scripts/social-proof-consent-lib.mjs b/scripts/social-proof-consent-lib.mjs index 7d75ffb..f0f546b 100644 --- a/scripts/social-proof-consent-lib.mjs +++ b/scripts/social-proof-consent-lib.mjs @@ -30,7 +30,7 @@ export function buildCandidateReview( generatedAt = new Date().toISOString() ) { validateRegistry(registry); - assertRegistryKey(registry, fingerprintKey); + validateRegistryKey(registry, fingerprintKey); assertIsoDate(generatedAt); if (!Array.isArray(excludedLogins) || excludedLogins.length === 0) { throw new Error('At least one owned or test account must be excluded'); @@ -99,7 +99,8 @@ export function validateFingerprintKey(value) { return decoded; } -function assertRegistryKey(registry, fingerprintKey) { +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)) { diff --git a/scripts/social-proof-consent.mjs b/scripts/social-proof-consent.mjs index 0dd4880..70eaa0c 100644 --- a/scripts/social-proof-consent.mjs +++ b/scripts/social-proof-consent.mjs @@ -10,6 +10,7 @@ import { createEmptyRegistry, validateFingerprintKey, validateRegistry, + validateRegistryKey, } from './social-proof-consent-lib.mjs'; const execFileAsync = promisify(execFile); @@ -167,14 +168,18 @@ export async function runCli(args, dependencies = {}) { return 'Created a private consent registry and account-fingerprint key.'; } if (command === 'review' && options.registry && options.key && options.exclude) { + const registry = await readRegistry(options.registry); + const fingerprintKey = await readFingerprintKey(options.key); + validateRegistryKey(registry, fingerprintKey); + const records = await readRecords(); const review = buildCandidateReview( - await readRecords(), - await readRegistry(options.registry), + records, + registry, options.exclude .split(',') .map(value => value.trim()) .filter(Boolean), - await readFingerprintKey(options.key) + fingerprintKey ); showReview(review); return `Reviewed ${review.candidates.length} outreach candidate(s) without saving identities.`; diff --git a/test/socialProofConsent.test.ts b/test/socialProofConsent.test.ts index 2e5066f..687eb80 100644 --- a/test/socialProofConsent.test.ts +++ b/test/socialProofConsent.test.ts @@ -169,6 +169,32 @@ describe('social proof consent workflow', () => { 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('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'); From c1c46fb9d11f824d9d256c3a61cfa2b59bcf6b29 Mon Sep 17 00:00:00 2001 From: neonwatty Date: Sun, 30 Aug 2026 10:20:47 -0700 Subject: [PATCH 5/5] fix: validate outreach exclusions before remote reads --- scripts/social-proof-consent-lib.mjs | 12 ++++++++---- scripts/social-proof-consent.mjs | 17 ++++++++--------- test/socialProofConsent.test.ts | 21 +++++++++++++++++++++ 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/scripts/social-proof-consent-lib.mjs b/scripts/social-proof-consent-lib.mjs index f0f546b..3af6ce7 100644 --- a/scripts/social-proof-consent-lib.mjs +++ b/scripts/social-proof-consent-lib.mjs @@ -32,10 +32,7 @@ export function buildCandidateReview( validateRegistry(registry); validateRegistryKey(registry, fingerprintKey); assertIsoDate(generatedAt); - if (!Array.isArray(excludedLogins) || excludedLogins.length === 0) { - throw new Error('At least one owned or test account must be excluded'); - } - const excluded = new Set(excludedLogins.map(normalizeGitHubLogin)); + const excluded = new Set(validateExcludedLogins(excludedLogins)); const decided = registry.entries.map(entry => Buffer.from(entry.accountFingerprint, 'hex')); const eligible = records .map(validateInstallationRecord) @@ -99,6 +96,13 @@ export function validateFingerprintKey(value) { 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'); diff --git a/scripts/social-proof-consent.mjs b/scripts/social-proof-consent.mjs index 70eaa0c..95a23e4 100644 --- a/scripts/social-proof-consent.mjs +++ b/scripts/social-proof-consent.mjs @@ -8,6 +8,7 @@ import { buildApprovedExport, buildCandidateReview, createEmptyRegistry, + validateExcludedLogins, validateFingerprintKey, validateRegistry, validateRegistryKey, @@ -168,19 +169,17 @@ export async function runCli(args, dependencies = {}) { return 'Created a private consent registry and account-fingerprint key.'; } if (command === 'review' && options.registry && options.key && options.exclude) { - const registry = await readRegistry(options.registry); - const fingerprintKey = await readFingerprintKey(options.key); - validateRegistryKey(registry, fingerprintKey); - const records = await readRecords(); - const review = buildCandidateReview( - records, - registry, + const excludedLogins = validateExcludedLogins( options.exclude .split(',') .map(value => value.trim()) - .filter(Boolean), - fingerprintKey + .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.`; } diff --git a/test/socialProofConsent.test.ts b/test/socialProofConsent.test.ts index 687eb80..172e478 100644 --- a/test/socialProofConsent.test.ts +++ b/test/socialProofConsent.test.ts @@ -195,6 +195,27 @@ describe('social proof consent workflow', () => { 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');