Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions docs/social-proof-consent.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
236 changes: 236 additions & 0 deletions scripts/social-proof-consent-lib.mjs
Original file line number Diff line number Diff line change
@@ -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');
}
}
Loading