OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop commits / 4fd4f6f

Add guarded catalogue moderation review
4fd4f6fdd684d5e1a833dcb686bfe8949709daa0
by bigdestiny2 <33146784+bigdestiny2@users.noreply.github.com> · 2026-07-29T21:56:49.000Z

Changed files (9) +991 -55

README.md +1 -0
……@@ -150,6 +150,7 @@ Three independent keypairs — BIP-39 identity, HiveRelay publisher key, Coresto
150150| [Release evidence log](./docs/RELEASE_SMOKE_EVIDENCE_LOG_2026-06-23.md) | Operator-fillable proof table for final PASS/FAIL/DEFER release evidence and announcement decision. |
151151| [Test command matrix](./docs/TEST-COMMAND-MATRIX-2026-06-23.md) | Separates deterministic local checks from GUI, real-DHT, third-party trust, release-drive, and mobile/native gates. |
152152| [App compatibility standard](./docs/PEARBROWSER-APP-COMPAT-STANDARD.md) | Author-facing release contract for apps targeting desktop and mobile. |
153| [Community review process](./docs/COMMUNITY_REVIEW_PROCESS.md) | Submission queue, due-diligence evidence, guarded relay decisions, audit records, and the separate catalogue-publication gate. |
153154| [Feature roadmap](./docs/P2P-BROWSER-FEATURE-ROADMAP.md) | Current shipped/next/parking-lot roadmap after the 2026 audit. |
154155
155156## Develop
backend/community-submit.cjs +210 -3
……@@ -11,10 +11,12 @@
1111// and this COMMUNITY bee (user-submitted, moderated). A submission publishes a
1212// manifest drive + seeds the app drive; the relay queues a pin request in
1313// `review` mode; the in-app moderator panel approves/rejects via the relay
14// management API and, on approve, the app entry is written into the community
15// bee so desktop readers see it.
14// management API. Publishing an approved entry into the shared community bee
15// remains a separate, secret-holding operator release step.
1616
1717const HEX64 = /^[0-9a-f]{64}$/i
18const MAX_INDEX_BYTES = 2 * 1024 * 1024
19const MAX_REVIEW_TEXT = 500
1820
1921/** Slug an arbitrary string into a stable, catalogue-safe app id. */
2022function slugify (s) {
……@@ -161,11 +163,216 @@ function communityBeeEntry (manifest, now = Date.now()) {
161163 return { key: 'app!' + id, value: entry }
162164}
163165
166function boundedText (value, max = MAX_REVIEW_TEXT) {
167 return String(value == null ? '' : value).trim().slice(0, max)
168}
169
170function reviewCheck (id, label, status, detail) {
171 return { id, label, status, detail: boundedText(detail, 1000) }
172}
173
174function manifestTarget (manifest = {}) {
175 if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return ''
176 return manifest.link || manifest.hyperUrl || manifest.url || manifest.driveKey || manifest.key || ''
177}
178
179function externalOriginsFromHtml (html) {
180 const origins = new Set()
181 const source = String(html || '').slice(0, MAX_INDEX_BYTES)
182 const re = /(?:src|href|action)\s*=\s*["'](https?:\/\/[^\s"'<>]+)/ig
183 let match
184 while ((match = re.exec(source)) && origins.size < 12) {
185 try { origins.add(new URL(match[1]).origin) } catch {}
186 }
187 return [...origins]
188}
189
190function duplicateSummary (duplicates) {
191 if (!Array.isArray(duplicates)) return []
192 return duplicates.slice(0, 12).map((entry) => ({
193 id: boundedText(entry && entry.id, 80),
194 name: boundedText(entry && entry.name, 160),
195 catalogName: boundedText(entry && (entry.catalogName || entry.source), 160),
196 driveKey: boundedText(entry && entry.driveKey, 64)
197 }))
198}
199
200function reviewEvidenceMatches (input = {}, report = null) {
201 if (!report || typeof report !== 'object') return false
202 const evidence = report.evidence && typeof report.evidence === 'object' ? report.evidence : {}
203 return Number(input.reviewedAt) === Number(report.checkedAt) &&
204 Number(input.reviewedDriveVersion) === Number(evidence.driveVersion) &&
205 Number.isFinite(Number(report.checkedAt)) &&
206 Number.isFinite(Number(evidence.driveVersion))
207}
208
209/**
210 * Turn fetched drive evidence into a deterministic moderation report.
211 *
212 * The I/O stays in backend/index.js. This helper deliberately distinguishes
213 * automated blockers from warnings that still need a human preview. Passing
214 * these checks means "eligible for an operator decision", not "safe code".
215 */
216function buildReviewReport (input = {}, now = Date.now()) {
217 const pending = input.pending && typeof input.pending === 'object' ? input.pending : {}
218 const appKey = boundedText(input.appKey || pending.appKey, 64).toLowerCase()
219 const publisherPubkey = boundedText(pending.publisherPubkey, 64).toLowerCase()
220 const manifest = input.manifest && typeof input.manifest === 'object' && !Array.isArray(input.manifest)
221 ? input.manifest
222 : null
223 const indexText = String(input.indexText || '').slice(0, MAX_INDEX_BYTES)
224 const indexBytes = Number.isFinite(input.indexBytes) ? input.indexBytes : Buffer.byteLength(indexText)
225 const driveVersion = Number.isFinite(input.driveVersion) ? input.driveVersion : 0
226 const duplicates = duplicateSummary(input.duplicates)
227 const checks = []
228
229 checks.push(HEX64.test(appKey)
230 ? reviewCheck('content-key', 'Content key', 'pass', 'Canonical 64-hex Hyperdrive key.')
231 : reviewCheck('content-key', 'Content key', 'block', 'The queue entry does not contain a valid 64-hex Hyperdrive key.'))
232
233 checks.push(HEX64.test(publisherPubkey)
234 ? reviewCheck('publisher', 'Publisher signature', 'pass', 'The relay queue carries a 32-byte publisher public key; the relay verifies the signed seed request.')
235 : reviewCheck('publisher', 'Publisher signature', 'block', 'No canonical publisher public key is visible. Approval requires a relay-verified signed seed request.'))
236
237 if (HEX64.test(publisherPubkey)) {
238 checks.push(reviewCheck('publisher-identity', 'Publisher identity', 'warning', 'The signing key authenticates the seed request, not a real-world identity or the manifest author claim. Verify provenance through an independent channel.'))
239 }
240
241 if (input.fetchError) {
242 checks.push(reviewCheck('availability', 'Content availability', 'block', 'The app drive could not be fetched: ' + boundedText(input.fetchError, 700)))
243 } else if (driveVersion <= 0) {
244 checks.push(reviewCheck('availability', 'Content availability', 'block', 'No replicated drive version was available for review.'))
245 } else {
246 checks.push(reviewCheck('availability', 'Content availability', 'pass', `Fetched Hyperdrive version ${driveVersion}.`))
247 checks.push(reviewCheck('mutable-content', 'Mutable content', 'warning', `This review covers drive version ${driveVersion}. The publisher can append a later version under the same key; updates need re-review or a version-pinned release policy.`))
248 }
249
250 if (!indexBytes) {
251 checks.push(reviewCheck('entrypoint', 'Browser entrypoint', 'block', 'Missing or empty /index.html. Community listings must open as browsable content.'))
252 } else if (indexBytes > MAX_INDEX_BYTES) {
253 checks.push(reviewCheck('entrypoint', 'Browser entrypoint', 'block', `/index.html is ${indexBytes} bytes; the review cap is ${MAX_INDEX_BYTES} bytes.`))
254 } else if (!/<(?:!doctype\s+html|html|head|body)\b/i.test(indexText)) {
255 checks.push(reviewCheck('entrypoint', 'Browser entrypoint', 'warning', `/index.html is ${indexBytes} bytes but does not look like an HTML document.`))
256 } else {
257 checks.push(reviewCheck('entrypoint', 'Browser entrypoint', 'pass', `/index.html is present (${indexBytes} bytes) and looks like HTML.`))
258 }
259
260 if (input.manifestError) {
261 checks.push(reviewCheck('manifest', 'App manifest', 'block', 'Invalid /manifest.json: ' + boundedText(input.manifestError, 700)))
262 } else if (!manifest) {
263 checks.push(reviewCheck('manifest', 'App manifest', 'block', 'Missing /manifest.json. Reviewers need publisher-supplied identity and metadata on the content drive.'))
264 } else if (!boundedText(manifest.name, 160)) {
265 checks.push(reviewCheck('manifest', 'App manifest', 'block', '/manifest.json is missing a non-empty name.'))
266 } else {
267 checks.push(reviewCheck('manifest', 'App manifest', 'pass', `Manifest identifies “${boundedText(manifest.name, 160)}”.`))
268 }
269
270 if (manifest) {
271 const rawTarget = manifestTarget(manifest)
272 const type = boundedText(manifest.type || manifest.kind, 80).toLowerCase()
273 const queuedType = boundedText(pending.contentType, 80).toLowerCase()
274 const nativeTarget = /^(?:pear|file):\/\//i.test(String(rawTarget)) ||
275 ['native', 'standalone', 'desktop', 'package', 'executable'].includes(type) ||
276 ['native', 'standalone', 'desktop', 'package', 'executable'].includes(queuedType) ||
277 typeof manifest.pearLink === 'string' || typeof manifest.executable === 'string'
278 let targetMismatch = false
279 let targetError = ''
280 if (rawTarget) {
281 const normalizeKey = typeof input.normalizeKey === 'function'
282 ? input.normalizeKey
283 : (value) => String(value || '').toLowerCase()
284 const derived = deriveKeyAndLink(rawTarget, normalizeKey)
285 targetError = derived.error || ''
286 targetMismatch = !targetError && derived.driveKey !== appKey
287 }
288 if (nativeTarget) {
289 checks.push(reviewCheck('delivery-boundary', 'Delivery boundary', 'block', 'Manifest requests native/executable delivery. The Community list accepts browsable hyper:// content only.'))
290 } else if (targetError) {
291 checks.push(reviewCheck('delivery-boundary', 'Delivery boundary', 'block', 'Manifest declares a target that is not a valid browsable Hyperdrive reference.'))
292 } else if (targetMismatch) {
293 checks.push(reviewCheck('delivery-boundary', 'Delivery boundary', 'block', 'Manifest target does not match the queued content key.'))
294 } else {
295 checks.push(reviewCheck('delivery-boundary', 'Delivery boundary', 'pass', 'No native executable target was declared and any manifest target matches the queued drive.'))
296 }
297 }
298
299 if (duplicates.length) {
300 const names = duplicates.map((entry) => entry.name || entry.id || entry.driveKey.slice(0, 12)).filter(Boolean)
301 checks.push(reviewCheck('duplicate', 'Catalogue duplication', 'warning', `This drive is already present in ${duplicates.length} loaded catalogue entr${duplicates.length === 1 ? 'y' : 'ies'}${names.length ? ': ' + names.join(', ') : ''}. Confirm whether this is an update or a duplicate submission.`))
302 } else {
303 checks.push(reviewCheck('duplicate', 'Catalogue duplication', 'pass', 'No matching drive is present in the catalogues currently loaded by this browser.'))
304 }
305
306 const externalOrigins = externalOriginsFromHtml(indexText)
307 const behaviorSignals = []
308 if (/\beval\s*\(|\bnew\s+Function\s*\(/i.test(indexText)) behaviorSignals.push('dynamic code execution')
309 if (/<iframe\b/i.test(indexText)) behaviorSignals.push('iframe embedding')
310 if (/<form\b/i.test(indexText)) behaviorSignals.push('form submission')
311 if (/\bserviceWorker\s*\.\s*register\s*\(/i.test(indexText)) behaviorSignals.push('service worker')
312 if (externalOrigins.length || behaviorSignals.length) {
313 const pieces = []
314 if (externalOrigins.length) pieces.push('external origins: ' + externalOrigins.join(', '))
315 if (behaviorSignals.length) pieces.push('behaviors: ' + behaviorSignals.join(', '))
316 checks.push(reviewCheck('page-behavior', 'Page behavior', 'warning', pieces.join(' · ') + '. Inspect the preview before approving.'))
317 } else {
318 checks.push(reviewCheck('page-behavior', 'Page behavior', 'pass', 'No external HTTP origins or high-signal dynamic page behaviors were found by the bounded static /index.html scan. Linked scripts were not inspected.'))
319 }
320
321 checks.push(reviewCheck('human-preview', 'Human review', 'warning', 'Automated checks cannot establish that an app is trustworthy. Open the preview, inspect its claims and requested behavior, then acknowledge the review.'))
322
323 const discoveredAt = typeof pending.discoveredAt === 'number'
324 ? pending.discoveredAt
325 : Date.parse(String(pending.discoveredAt || ''))
326 if (Number.isFinite(discoveredAt) && discoveredAt > 0 && now - discoveredAt > 30 * 24 * 60 * 60 * 1000) {
327 checks.push(reviewCheck('queue-age', 'Queue freshness', 'warning', 'This request has been pending for more than 30 days. Confirm the content has not gone stale.'))
328 }
329
330 const summary = checks.reduce((counts, check) => {
331 counts[check.status] = (counts[check.status] || 0) + 1
332 return counts
333 }, { pass: 0, warning: 0, block: 0 })
334
335 return {
336 appKey,
337 publisherPubkey,
338 previewUrl: HEX64.test(appKey) ? `hyper://${appKey}/` : '',
339 status: summary.block ? 'blocked' : 'review-needed',
340 approvalAllowed: summary.block === 0,
341 requiresAcknowledgement: summary.warning > 0,
342 checkedAt: now,
343 summary,
344 checks,
345 manifest: manifest
346 ? {
347 id: boundedText(manifest.id, 80),
348 name: boundedText(manifest.name, 160),
349 description: boundedText(manifest.description, 1000),
350 author: boundedText(manifest.author, 160),
351 version: boundedText(manifest.version, 40),
352 categories: Array.isArray(manifest.categories) ? manifest.categories.map((value) => boundedText(value, 80)).filter(Boolean).slice(0, 12) : [],
353 driveKey: appKey,
354 link: HEX64.test(appKey) ? `hyper://${appKey}/` : '',
355 type: 'hypersite'
356 }
357 : null,
358 evidence: {
359 driveVersion,
360 indexBytes,
361 externalOrigins,
362 behaviorSignals,
363 duplicates
364 }
365 }
366}
367
164368module.exports = {
165369 HEX64,
370 MAX_INDEX_BYTES,
166371 slugify,
167372 deriveKeyAndLink,
168373 buildSubmissionManifest,
169374 manageRequest,
170 communityBeeEntry
375 communityBeeEntry,
376 buildReviewReport,
377 reviewEvidenceMatches
171378}
backend/constants.js +8 -6
……@@ -180,12 +180,14 @@ const CMD_NOSTR_QUERY = 192 // { filter } → { events } (NIP-01 filter o
180180
181181// Community app submission + moderation (2026-06-22). Anyone can submit their
182182// app to the COMMUNITY catalogue (5d961fdc…); an in-app moderator panel reviews
183// the relay queue and, on approve, writes the app into the community Hyperbee +
184// re-pins it. See backend/community-submit.js + ui/shell.js (submit form + panel).
185const CMD_SUBMIT_APP = 210 // { name, link|driveKey, description?, author?, categories?, iconData? } → publish manifest + seed → { id, status:'pending-review' }
183// relay pin requests. Approval authorizes replication only; publishing into the
184// shared community Hyperbee is a separate operator step. See
185// backend/community-submit.cjs + ui/shell.js (submit form + panel).
186const CMD_SUBMIT_APP = 210 // { name, link|driveKey, description?, author?, categories?, iconData? } → publish receipt + seed → { id, status:'pending-review'|'awaiting-relay' }
186187const CMD_MOD_PENDING = 211 // → { pending: [...] } pulled from the relay review queue
187const CMD_MOD_APPROVE = 212 // { id } → relay approve + write app into community bee + re-pin
188const CMD_MOD_REJECT = 213 // { id, reason? } → relay reject
188const CMD_MOD_APPROVE = 212 // { appKey, acknowledged, reviewedAt, reviewedDriveVersion, note } → guarded relay approval + local audit
189const CMD_MOD_REJECT = 213 // { appKey, reason, note? } → relay reject + local audit
190const CMD_MOD_REVIEW = 214 // { appKey } → fetch content + manifest and return due-diligence evidence
189191
190192// Browser chrome-owned local AI. Unlike /api/ai/* these commands are not
191193// callable by Hyperdrive pages: only PearBrowser's renderer can use them.
……@@ -332,7 +334,7 @@ module.exports = {
332334 CMD_IDENTITY_BINDING_PUBLISH, CMD_IDENTITY_BINDING_RESOLVE, CMD_SEARCH_FEDERATED,
333335 CMD_NOSTR_GET_IDENTITY, CMD_NOSTR_BIND, CMD_NOSTR_REVOKE,
334336 CMD_NOSTR_PUBLISH, CMD_NOSTR_QUERY,
335 CMD_SUBMIT_APP, CMD_MOD_PENDING, CMD_MOD_APPROVE, CMD_MOD_REJECT,
337 CMD_SUBMIT_APP, CMD_MOD_PENDING, CMD_MOD_APPROVE, CMD_MOD_REJECT, CMD_MOD_REVIEW,
336338 CMD_ASK_BROWSER_CAPABILITIES, CMD_ASK_BROWSER_START, CMD_ASK_BROWSER_CANCEL,
337339 CMD_SHIELD_STATUS, CMD_SHIELD_LOAD_LIST, CMD_SHIELD_REMOVE_LIST,
338340 CMD_SHIELD_SET_ALLOW, CMD_SHIELD_SET_STRICT,
backend/index.js +275 -18
……@@ -846,11 +846,160 @@ rpc.handle(C.CMD_MYCATALOG_UPDATE_APP, async (data) => {
846846
847847// Operator's relay management endpoint + API key (set via the moderator panel,
848848// persisted in userdata settings). Empty until configured.
849const moderationPendingByKey = new Map()
850const moderationReviewCache = new Map()
851let moderationPendingMode = null
852let moderationPendingLoadedAt = 0
853const MODERATION_REVIEW_CACHE_MS = 5 * 60 * 1000
854const MAX_REVIEW_INDEX_BYTES = communitySubmit.MAX_INDEX_BYTES
855const MAX_REVIEW_MANIFEST_BYTES = 256 * 1024
856const MAX_RELAY_MANAGE_RESPONSE_BYTES = 2 * 1024 * 1024
857
849858async function getModSettings () {
850859 try {
851860 const s = (await requireUserData().getSettings()) || {}
852 return { baseUrl: s.relayManageUrl || '', apiKey: s.relayManageKey || '' }
853 } catch { return { baseUrl: '', apiKey: '' } }
861 return {
862 baseUrl: s.relayManageUrl || '',
863 apiKey: s.relayManageKey || '',
864 audit: Array.isArray(s.communityReviewAudit) ? s.communityReviewAudit.slice(0, 50) : []
865 }
866 } catch { return { baseUrl: '', apiKey: '', audit: [] } }
867}
868
869function withReviewTimeout (promise, ms, label) {
870 let timer
871 const guard = new Promise((resolve, reject) => {
872 timer = setTimeout(() => reject(new Error(label + ' timed out')), ms)
873 })
874 return Promise.race([promise, guard]).finally(() => clearTimeout(timer))
875}
876
877function boundedReviewText (value, max = 1000) {
878 return String(value == null ? '' : value).trim().slice(0, max)
879}
880
881async function readBoundedReviewFile (drive, path, maxBytes, timeoutMs, label) {
882 const entry = await withReviewTimeout(drive.entry(path, { wait: true }), timeoutMs, label + ' metadata fetch')
883 const blob = entry && entry.value && entry.value.blob
884 if (!blob) return { buffer: null, byteLength: 0, tooLarge: false }
885
886 const byteLength = Number(blob.byteLength) || 0
887 if (byteLength > maxBytes) return { buffer: null, byteLength, tooLarge: true }
888
889 const buffer = await withReviewTimeout(drive.get(path, { wait: true }), timeoutMs, label + ' fetch')
890 const actualByteLength = buffer ? buffer.length : 0
891 return {
892 buffer: actualByteLength > maxBytes ? null : buffer,
893 byteLength: actualByteLength,
894 tooLarge: actualByteLength > maxBytes
895 }
896}
897
898async function appendCommunityReviewAudit (entry) {
899 const store = requireUserData()
900 const settings = (await store.getSettings()) || {}
901 const previous = Array.isArray(settings.communityReviewAudit) ? settings.communityReviewAudit : []
902 const record = {
903 appKey: boundedReviewText(entry.appKey, 64).toLowerCase(),
904 publisherPubkey: boundedReviewText(entry.publisherPubkey, 64).toLowerCase(),
905 action: entry.action === 'approve' ? 'approve' : 'reject',
906 reason: boundedReviewText(entry.reason, 500),
907 note: boundedReviewText(entry.note, 1000),
908 reviewStatus: boundedReviewText(entry.reviewStatus, 40),
909 summary: entry.summary && typeof entry.summary === 'object'
910 ? {
911 pass: Number(entry.summary.pass) || 0,
912 warning: Number(entry.summary.warning) || 0,
913 block: Number(entry.summary.block) || 0
914 }
915 : null,
916 decidedAt: Date.now()
917 }
918 await store.setSettings({ communityReviewAudit: [record, ...previous].slice(0, 100) })
919 return record
920}
921
922async function appendCommunityReviewAuditSafely (entry) {
923 try {
924 return { audit: await appendCommunityReviewAudit(entry), auditWarning: '' }
925 } catch (err) {
926 const auditWarning = 'Relay decision succeeded, but the local audit record could not be saved: ' + boundedReviewText(err && err.message, 300)
927 console.error('[mod] ' + auditWarning)
928 return { audit: null, auditWarning }
929 }
930}
931
932async function reviewPendingSubmission (appKey, pending = {}, opts = {}) {
933 const normalizedKey = normalizeDriveKey(String(appKey || ''))
934 if (!/^[0-9a-f]{64}$/i.test(normalizedKey)) throw new Error('Due diligence needs a valid 64-hex appKey.')
935 const cached = moderationReviewCache.get(normalizedKey)
936 if (!opts.force && cached && Date.now() - cached.checkedAt < MODERATION_REVIEW_CACHE_MS) return cached
937
938 let driveVersion = 0
939 let indexBuf = null
940 let indexBytes = 0
941 let manifestBuf = null
942 let manifest = null
943 let manifestError = ''
944 let fetchError = ''
945
946 try {
947 const drive = await withReviewTimeout(ensureBrowseDrive(normalizedKey), 12000, 'Drive open')
948 try { await withReviewTimeout(drive.update({ wait: true }), 10000, 'Drive update') } catch (_) {}
949 driveVersion = Number(drive.version) || 0
950 try {
951 const indexFile = await readBoundedReviewFile(drive, '/index.html', MAX_REVIEW_INDEX_BYTES, 12000, 'index.html')
952 indexBuf = indexFile.buffer
953 indexBytes = indexFile.byteLength
954 } catch (err) {
955 fetchError = err && err.message ? err.message : String(err)
956 }
957 try {
958 const manifestFile = await readBoundedReviewFile(drive, '/manifest.json', MAX_REVIEW_MANIFEST_BYTES, 8000, 'manifest.json')
959 manifestBuf = manifestFile.buffer
960 if (manifestFile.tooLarge) {
961 manifestBuf = null
962 manifestError = `/manifest.json exceeds the ${MAX_REVIEW_MANIFEST_BYTES}-byte review cap.`
963 }
964 } catch (err) {
965 if (!/timed out/i.test(err && err.message)) manifestError = err && err.message ? err.message : String(err)
966 }
967 } catch (err) {
968 fetchError = err && err.message ? err.message : String(err)
969 }
970
971 if (manifestBuf) {
972 if (manifestBuf.length > MAX_REVIEW_MANIFEST_BYTES) {
973 manifestError = `/manifest.json exceeds the ${MAX_REVIEW_MANIFEST_BYTES}-byte review cap.`
974 } else {
975 try { manifest = safeJSONParse(Buffer.from(manifestBuf).toString('utf8')) } catch (err) {
976 manifestError = err && err.message ? err.message : String(err)
977 }
978 }
979 }
980
981 const duplicates = catalogManager
982 ? catalogManager.getAggregatedApps().filter((app) => {
983 const driveKey = normalizeDriveKey(String((app && app.driveKey) || ''))
984 const linkKey = driveKeyFromUrl(app && app.link)
985 return driveKey === normalizedKey || linkKey === normalizedKey
986 })
987 : []
988
989 const report = communitySubmit.buildReviewReport({
990 appKey: normalizedKey,
991 pending,
992 manifest,
993 manifestError,
994 indexText: indexBuf ? Buffer.from(indexBuf).toString('utf8') : '',
995 indexBytes,
996 driveVersion,
997 duplicates,
998 fetchError,
999 normalizeKey: normalizeDriveKey
1000 }, Date.now())
1001 moderationReviewCache.set(normalizedKey, report)
1002 return report
8541003}
8551004
8561005// Minimal JSON HTTP(S) round-trip for the relay management API. bare-http1 has
……@@ -858,12 +1007,20 @@ async function getModSettings () {
8581007// { status, json, text }.
8591008function relayManageHttp (spec, timeoutMs = 15000) {
8601009 return new Promise((resolve, reject) => {
1010 let settled = false
1011 let timer
1012 const fail = (err) => {
1013 if (settled) return
1014 settled = true
1015 clearTimeout(timer)
1016 reject(err)
1017 }
8611018 let u
862 try { u = new URL(spec.url) } catch { return reject(new Error('Bad relay URL: ' + spec.url)) }
1019 try { u = new URL(spec.url) } catch { return fail(new Error('Bad relay URL: ' + spec.url)) }
8631020 const isHttps = u.protocol === 'https:'
8641021 let lib
8651022 try { lib = isHttps ? require('bare-https') : require('bare-http1') } catch (e) {
866 return reject(new Error('HTTP client unavailable: ' + (e && e.message)))
1023 return fail(new Error('HTTP client unavailable: ' + (e && e.message)))
8671024 }
8681025 const req = lib.request({
8691026 method: spec.method || 'GET',
……@@ -873,18 +1030,30 @@ function relayManageHttp (spec, timeoutMs = 15000) {
8731030 headers: spec.headers || {}
8741031 }, (res) => {
8751032 const chunks = []
876 res.on('data', (c) => chunks.push(c))
1033 let bytes = 0
1034 res.on('data', (c) => {
1035 if (settled) return
1036 bytes += c.length
1037 if (bytes > MAX_RELAY_MANAGE_RESPONSE_BYTES) {
1038 try { req.destroy() } catch {}
1039 fail(new Error('Relay management response exceeded the 2 MB safety limit.'))
1040 return
1041 }
1042 chunks.push(c)
1043 })
8771044 res.on('end', () => {
1045 if (settled) return
1046 settled = true
8781047 clearTimeout(timer)
8791048 const text = Buffer.concat(chunks).toString('utf8')
8801049 let json = null
8811050 try { json = text ? JSON.parse(text) : null } catch {}
8821051 resolve({ status: res.statusCode, json, text })
8831052 })
884 res.on('error', (err) => { clearTimeout(timer); reject(err) })
1053 res.on('error', fail)
8851054 })
886 const timer = setTimeout(() => { try { req.destroy() } catch {} ; reject(new Error('Relay request timed out')) }, timeoutMs)
887 req.on('error', (err) => { clearTimeout(timer); reject(err) })
1055 timer = setTimeout(() => { try { req.destroy() } catch {} ; fail(new Error('Relay request timed out')) }, timeoutMs)
1056 req.on('error', fail)
8881057 if (spec.body) req.write(spec.body)
8891058 req.end()
8901059 })
……@@ -926,6 +1095,7 @@ rpc.handle(C.CMD_SUBMIT_APP, async (data = {}) => {
9261095 // 1) Publish the submission manifest drive (durable metadata: name/desc/icon)
9271096 // so the moderator + mobile firehose can read it later. seed:true pins it.
9281097 let manifestKey = null
1098 let receiptWarning = ''
9291099 try {
9301100 const mdrive = await hiveRelay.publish(
9311101 [{ path: '/manifest.json', content: JSON.stringify(manifest, null, 2) }],
……@@ -936,7 +1106,9 @@ rpc.handle(C.CMD_SUBMIT_APP, async (data = {}) => {
9361106 }
9371107 } catch (err) {
9381108 console.error('[submit] publish manifest failed:', err && err.message)
1109 receiptWarning = 'The submission receipt could not be published yet.'
9391110 }
1111 if (!manifestKey && !receiptWarning) receiptWarning = 'The submission receipt publisher returned no drive key.'
9401112
9411113 // 2) Seed the app's CONTENT drive. In `review` mode this queues a pin request
9421114 // in the relay's pending queue for the moderator to approve.
……@@ -949,12 +1121,22 @@ rpc.handle(C.CMD_SUBMIT_APP, async (data = {}) => {
9491121 }
9501122
9511123 console.log(`[submit] queued "${manifest.name}" (${id}) drive=${driveKey.slice(0, 8)} acceptances=${acceptances}`)
952 return { ok: true, id, driveKey, manifestKey, acceptances, status: 'pending-review', manifest }
1124 return {
1125 ok: true,
1126 id,
1127 driveKey,
1128 manifestKey,
1129 receiptPublished: !!manifestKey,
1130 receiptWarning,
1131 acceptances,
1132 status: acceptances > 0 ? 'pending-review' : 'awaiting-relay',
1133 manifest
1134 }
9531135})
9541136
9551137rpc.handle(C.CMD_MOD_PENDING, async () => {
9561138 await whenReady()
957 const { baseUrl, apiKey } = await getModSettings()
1139 const { baseUrl, apiKey, audit } = await getModSettings()
9581140 const spec = communitySubmit.manageRequest('pending', { baseUrl, apiKey })
9591141 if (spec.error) throw new Error(spec.error)
9601142 const res = await relayManageHttp(spec)
……@@ -963,45 +1145,120 @@ rpc.handle(C.CMD_MOD_PENDING, async () => {
9631145 const requests = Array.isArray(res.json.requests) ? res.json.requests : []
9641146 const toHex = (v) => typeof v === 'string' ? v : (v ? Buffer.from(v.data || v).toString('hex') : '')
9651147 const pending = requests.map((r) => ({
966 appKey: toHex(r.appKey),
1148 appKey: normalizeDriveKey(toHex(r.appKey)),
9671149 publisherPubkey: toHex(r.publisherPubkey),
9681150 discoveredAt: r.discoveredAt || null,
9691151 ttlSeconds: r.ttlSeconds || null,
970 currentRelays: r.currentRelays || 0
1152 currentRelays: r.currentRelays || 0,
1153 replicationFactor: r.replicationFactor || null,
1154 source: boundedReviewText(r.source, 80),
1155 contentType: boundedReviewText(r.contentType || r.type, 80),
1156 appId: boundedReviewText(r.appId || r.id, 80),
1157 name: boundedReviewText(r.name, 160),
1158 author: boundedReviewText(r.author, 160),
1159 version: boundedReviewText(r.version, 40),
1160 categories: Array.isArray(r.categories) ? r.categories.map((value) => boundedReviewText(value, 80)).filter(Boolean).slice(0, 12) : [],
1161 privacyTier: boundedReviewText(r.privacyTier, 40),
1162 storageClass: boundedReviewText(r.storageClass, 40),
1163 availabilityClass: boundedReviewText(r.availabilityClass, 40),
1164 blind: r.blind === true
9711165 }))
972 return { ok: true, mode: res.json.mode || null, count: pending.length, pending }
1166 moderationPendingMode = boundedReviewText(res.json.mode, 40) || null
1167 moderationPendingLoadedAt = Date.now()
1168 moderationPendingByKey.clear()
1169 moderationReviewCache.clear()
1170 for (const request of pending) if (request.appKey) moderationPendingByKey.set(request.appKey, request)
1171 return { ok: true, mode: moderationPendingMode, count: pending.length, pending, audit }
1172})
1173
1174rpc.handle(C.CMD_MOD_REVIEW, async (data = {}) => {
1175 await whenReady()
1176 const appKey = normalizeDriveKey(String(data.appKey || data.driveKey || ''))
1177 const pending = moderationPendingByKey.get(appKey)
1178 if (!pending) throw new Error('This app is not in the loaded relay queue. Refresh pending submissions before reviewing it.')
1179 return await reviewPendingSubmission(appKey, pending, { force: data.force === true })
9731180})
9741181
9751182rpc.handle(C.CMD_MOD_APPROVE, async (data = {}) => {
9761183 await whenReady()
9771184 const appKey = normalizeDriveKey(String(data.appKey || data.driveKey || ''))
1185 if (moderationPendingMode !== 'review') throw new Error('Relay decisions require a freshly loaded queue from a relay in review mode.')
1186 if (Date.now() - moderationPendingLoadedAt > MODERATION_REVIEW_CACHE_MS) throw new Error('The relay queue is stale. Refresh pending submissions before approving.')
1187 const pending = moderationPendingByKey.get(appKey)
1188 if (!pending) throw new Error('This app is not in the loaded relay queue. Refresh pending submissions before approving it.')
1189 const cachedReview = moderationReviewCache.get(appKey)
1190 const reviewedDriveVersion = Number(data.reviewedDriveVersion)
1191 if (!communitySubmit.reviewEvidenceMatches(data, cachedReview)) {
1192 throw new Error('The due-diligence evidence does not match this approval. Re-run the review before approving.')
1193 }
1194 const review = await reviewPendingSubmission(appKey, pending, { force: true })
1195 if (review.evidence.driveVersion !== reviewedDriveVersion) {
1196 throw new Error(`The app changed from drive version ${reviewedDriveVersion} to ${review.evidence.driveVersion} after review. Inspect the new version before approving.`)
1197 }
1198 if (!review.approvalAllowed) {
1199 const blockers = review.checks.filter((check) => check.status === 'block').map((check) => check.label).join(', ')
1200 throw new Error('Due diligence blocked approval: ' + (blockers || 'review evidence failed') + '.')
1201 }
1202 if (review.requiresAcknowledgement && data.acknowledged !== true) {
1203 throw new Error('A reviewer must preview the app and acknowledge the due-diligence warnings before approval.')
1204 }
1205 const note = boundedReviewText(data.note, 1000)
1206 if (!note) throw new Error('Record a reviewer note describing the due diligence performed before approval.')
9781207 const { baseUrl, apiKey } = await getModSettings()
9791208 const spec = communitySubmit.manageRequest('approve', { baseUrl, apiKey, appKey })
9801209 if (spec.error) throw new Error(spec.error)
9811210 const res = await relayManageHttp(spec)
9821211 if (res.status === 401) throw new Error('Relay rejected the moderator API key (401).')
9831212 if (res.status >= 400) throw new Error('Relay approve failed (HTTP ' + res.status + ').')
984 // Best-effort promote into the community bee so desktop readers see it.
1213 // Report the separate catalogue-publication gate; this currently performs no
1214 // write because the secret-holding publisher is intentionally out-of-process.
9851215 let promoted = null
986 if (data.manifest && typeof data.manifest === 'object') {
987 try { promoted = await promoteToCommunityCatalogue(data.manifest) } catch (err) {
1216 if (review.manifest) {
1217 try { promoted = await promoteToCommunityCatalogue(review.manifest) } catch (err) {
9881218 console.error('[mod] community-bee write failed:', err && err.message)
9891219 promoted = { ok: false, error: err && err.message }
9901220 }
9911221 }
992 return { ok: true, appKey, promoted }
1222 const { audit, auditWarning } = await appendCommunityReviewAuditSafely({
1223 appKey,
1224 publisherPubkey: review.publisherPubkey,
1225 action: 'approve',
1226 note,
1227 reviewStatus: review.status,
1228 summary: review.summary
1229 })
1230 moderationPendingByKey.delete(appKey)
1231 moderationReviewCache.delete(appKey)
1232 return { ok: true, appKey, promoted, review, audit, auditWarning }
9931233})
9941234
9951235rpc.handle(C.CMD_MOD_REJECT, async (data = {}) => {
9961236 await whenReady()
9971237 const appKey = normalizeDriveKey(String(data.appKey || data.driveKey || ''))
1238 const reason = boundedReviewText(data.reason, 500)
1239 if (!reason) throw new Error('A rejection reason is required for the moderation audit trail.')
1240 if (moderationPendingMode !== 'review') throw new Error('Relay decisions require a freshly loaded queue from a relay in review mode.')
1241 if (Date.now() - moderationPendingLoadedAt > MODERATION_REVIEW_CACHE_MS) throw new Error('The relay queue is stale. Refresh pending submissions before rejecting.')
1242 const pending = moderationPendingByKey.get(appKey)
1243 if (!pending) throw new Error('This app is not in the loaded relay queue. Refresh pending submissions before rejecting it.')
9981244 const { baseUrl, apiKey } = await getModSettings()
9991245 const spec = communitySubmit.manageRequest('reject', { baseUrl, apiKey, appKey })
10001246 if (spec.error) throw new Error(spec.error)
10011247 const res = await relayManageHttp(spec)
10021248 if (res.status === 401) throw new Error('Relay rejected the moderator API key (401).')
10031249 if (res.status >= 400) throw new Error('Relay reject failed (HTTP ' + res.status + ').')
1004 return { ok: true, appKey }
1250 const { audit, auditWarning } = await appendCommunityReviewAuditSafely({
1251 appKey,
1252 publisherPubkey: pending.publisherPubkey,
1253 action: 'reject',
1254 reason,
1255 note: data.note,
1256 reviewStatus: moderationReviewCache.get(appKey)?.status || 'not-run',
1257 summary: moderationReviewCache.get(appKey)?.summary || null
1258 })
1259 moderationPendingByKey.delete(appKey)
1260 moderationReviewCache.delete(appKey)
1261 return { ok: true, appKey, audit, auditWarning }
10051262})
10061263
10071264// Collaborative (Autobee) catalog authoring — Rollout Phase 3. All gated by
docs/COMMUNITY_REVIEW_PROCESS.md +82 -0
……@@ -0,0 +1,82 @@
1# Community catalogue review process
2
3PearBrowser separates content replication from catalogue endorsement. A relay
4pin approval means a relay may store and serve a drive. It does **not** by
5itself publish that drive into the shared Community catalogue.
6
7## Submission lifecycle
8
91. A publisher opens **Apps → Submit your app**, supplies a browsable
10 `hyper://` target, and submits it.
112. PearBrowser publishes a bounded submission receipt and sends a signed seed
12 request for the content drive.
133. A HiveRelay running in `review` acceptance mode adds that request to its
14 authenticated management queue.
154. An operator opens **Apps → Moderator tools**, loads the queue, and runs due
16 diligence for one request at a time.
175. The operator previews the site, records a note, and either approves the
18 relay pin or rejects the request with a reason.
196. Approved metadata is published into the Community Hyperbee through the
20 separate secret-holding catalogue release process. Until this final step,
21 the content may be replicated but is not listed for all users.
22
23A broadcast with zero initial relay acknowledgements is reported as
24`awaiting-relay`, not as a confirmed pending review. The client may retry the
25signed seed request, but an operator must see it in a relay queue before any
26decision is possible.
27
28## Automated due diligence
29
30The browser fetches evidence directly from the queued content drive and checks:
31
32- canonical 64-hex content key and visible publisher public key;
33- replicated Hyperdrive version and a bounded `/index.html` entrypoint;
34- a parseable `/manifest.json` with a non-empty app name;
35- manifest target consistency with the queued drive;
36- rejection of native, `pear://`, `file://`, package, and executable targets;
37- duplicate drive entries in the catalogues currently loaded by the browser;
38- external HTTP origins and high-signal page behavior such as dynamic code,
39 iframes, forms, or service-worker registration;
40- queue age and other provenance metadata exposed by the relay.
41
42Missing content, a missing publisher key, an invalid/missing manifest, key
43mismatch, or an executable delivery declaration is a blocker. Duplicates,
44behavioral signals, mutable-drive updates, and the fact that a signing key does
45not prove a real-world identity are warnings. The page scan is bounded and does
46not inspect linked scripts. Automated checks never declare an app safe: every
47eligible approval still requires the operator to open the preview and
48explicitly acknowledge the warnings.
49
50## Decision records
51
52Approvals and rejections are written to a bounded local audit trail containing
53the app key, publisher key, action, reviewer note or rejection reason, decision
54time, and automated-check counts. API credentials are never copied into audit
55records.
56
57Rejection requires a reason. Approval fails closed if evidence has blockers or
58the human-review acknowledgement or reviewer note is missing. Decisions also
59require a queue refreshed within the last five minutes from a relay reporting
60`review` mode. Approval is bound to the exact Hyperdrive version shown in the
61review report; if the content changes before the decision, the reviewer must
62fetch and inspect new evidence. File-size caps are checked from authenticated
63Hyperdrive metadata before content is downloaded. The relay management API
64receives only the app key because its current protocol does not carry reviewer
65notes; the detailed record stays in PearBrowser until a signed shared
66moderation log is introduced.
67
68## Operator checklist
69
70- Confirm the relay reports `review` mode.
71- Run or refresh due diligence.
72- Compare the manifest name, author, version, and purpose with the publisher's
73 claimed identity through an independent channel when possible.
74- Open the preview and exercise the main flows without granting unnecessary
75 permissions.
76- Investigate external origins, forms, iframes, dynamic execution, and duplicate
77 listings rather than treating them as automatic guilt.
78- Record what was checked.
79- Approve the relay pin only when the evidence is coherent, or reject with an
80 actionable reason.
81- Publish approved metadata through the Community catalogue release process and
82 verify the resulting catalogue from a fresh reader.
styles.css +137 -0
……@@ -2364,6 +2364,143 @@ p { color: var(--pear-color-muted); margin-bottom: 12px; }
23642364.search-fed-toggle input { cursor: pointer; }
23652365.search-fed-toggle .fed-status { color: var(--pear-color-link); font-style: italic; }
23662366
2367/* Community catalogue moderation queue. */
2368.mod-process {
2369 display: flex;
2370 align-items: center;
2371 flex-wrap: wrap;
2372 gap: 7px;
2373 margin: 10px 0 16px;
2374 padding: 10px 12px;
2375 border: 1px solid var(--pear-color-line);
2376 border-radius: 8px;
2377 background: var(--pear-color-surface-soft);
2378 color: var(--pear-color-muted);
2379 font-size: 12px;
2380}
2381.mod-process strong {
2382 display: inline-flex;
2383 align-items: center;
2384 justify-content: center;
2385 width: 20px;
2386 height: 20px;
2387 margin-right: 3px;
2388 border-radius: 50%;
2389 background: var(--pear-color-surface);
2390 color: var(--pear-color-text);
2391}
2392.mod-mode,
2393.mod-check {
2394 display: inline-flex;
2395 align-items: center;
2396 min-height: 22px;
2397 padding: 1px 8px;
2398 border: 1px solid var(--pear-color-line);
2399 border-radius: 999px;
2400 color: var(--pear-color-muted);
2401 background: var(--pear-color-surface);
2402 font-size: 10px;
2403 font-weight: 700;
2404 text-transform: uppercase;
2405 letter-spacing: 0.04em;
2406 white-space: nowrap;
2407}
2408.mod-mode.pass,
2409.mod-check.pass { color: var(--pear-color-success); border-color: rgba(22,131,79,0.28); background: rgba(22,131,79,0.08); }
2410.mod-mode.warning,
2411.mod-check.warning { color: var(--pear-color-warning); border-color: rgba(185,120,18,0.30); background: rgba(185,120,18,0.10); }
2412.mod-mode.block,
2413.mod-check.block { color: var(--pear-color-error); border-color: rgba(194,65,47,0.30); background: rgba(194,65,47,0.08); }
2414.mod-pending { display: grid; gap: 12px; margin-top: 12px; }
2415.mod-review-card {
2416 display: grid;
2417 gap: 12px;
2418 padding: 14px;
2419 border: 1px solid var(--pear-color-line);
2420 border-radius: 10px;
2421 background: var(--pear-color-canvas);
2422}
2423.mod-review-head,
2424.mod-review-actions,
2425.mod-decision-actions,
2426.mod-summary {
2427 display: flex;
2428 align-items: flex-start;
2429 justify-content: space-between;
2430 gap: 8px;
2431 flex-wrap: wrap;
2432}
2433.mod-review-actions,
2434.mod-decision-actions,
2435.mod-summary { justify-content: flex-start; }
2436.mod-key {
2437 margin: 3px 0;
2438 color: var(--pear-color-muted);
2439 font: 10px/1.4 var(--pear-font-mono);
2440 overflow-wrap: anywhere;
2441}
2442.mod-manifest {
2443 padding: 10px 12px;
2444 border-left: 3px solid var(--pear-color-link);
2445 border-radius: 6px;
2446 background: var(--pear-color-surface);
2447 color: var(--pear-color-muted);
2448 font-size: 12px;
2449}
2450.mod-manifest strong { color: var(--pear-color-text); }
2451.mod-checks { display: grid; gap: 6px; }
2452.mod-check-row {
2453 display: grid;
2454 grid-template-columns: 22px 1fr;
2455 gap: 8px;
2456 padding: 8px 10px;
2457 border: 1px solid var(--pear-color-line);
2458 border-radius: 7px;
2459 background: var(--pear-color-surface);
2460 color: var(--pear-color-muted);
2461 font-size: 11px;
2462}
2463.mod-check-row strong { color: var(--pear-color-text); }
2464.mod-check-row.pass { border-left: 3px solid var(--pear-color-success); }
2465.mod-check-row.warning { border-left: 3px solid var(--pear-color-warning); }
2466.mod-check-row.block { border-left: 3px solid var(--pear-color-error); }
2467.mod-check-icon { font-size: 15px; font-weight: 800; text-align: center; }
2468.mod-check-row.pass .mod-check-icon { color: var(--pear-color-success); }
2469.mod-check-row.warning .mod-check-icon { color: var(--pear-color-warning); }
2470.mod-check-row.block .mod-check-icon { color: var(--pear-color-error); }
2471.mod-ack {
2472 display: flex;
2473 align-items: flex-start;
2474 gap: 8px;
2475 padding: 10px 12px;
2476 border: 1px solid rgba(185,120,18,0.28);
2477 border-radius: 8px;
2478 background: rgba(185,120,18,0.08);
2479 color: var(--pear-color-text);
2480 font-size: 12px;
2481 cursor: pointer;
2482}
2483.mod-ack input { margin-top: 2px; accent-color: var(--pear-color-warning); }
2484.mod-textarea { min-height: 72px; resize: vertical; font-family: inherit; }
2485.mod-audit { margin-top: 16px; border-top: 1px solid var(--pear-color-line); padding-top: 12px; }
2486.mod-audit summary { cursor: pointer; color: var(--pear-color-muted); font-size: 12px; }
2487.mod-audit-row {
2488 display: grid;
2489 grid-template-columns: auto minmax(110px, auto) 1fr auto;
2490 gap: 8px;
2491 align-items: center;
2492 padding: 8px 0;
2493 border-bottom: 1px solid var(--pear-color-surface-soft);
2494 color: var(--pear-color-muted);
2495 font-size: 11px;
2496}
2497.mod-audit-row code { color: var(--pear-color-text); }
2498.mod-audit-row time { white-space: nowrap; }
2499@media (max-width: 760px) {
2500 .mod-audit-row { grid-template-columns: auto 1fr; }
2501 .mod-audit-row span:nth-child(3), .mod-audit-row time { grid-column: 1 / -1; }
2502}
2503
23672504/* Lighthouse Phase 2 — trusted-peers (contact invite exchange) panel. */
23682505.trusted-peers { margin: 0 0 16px; border: 1px solid var(--pear-color-line); border-radius: 8px; background: var(--pear-color-canvas); }
23692506.trusted-peers summary { padding: 10px 12px; cursor: pointer; font-size: 13px; color: var(--pear-color-text); user-select: none; }
test/community-submit.test.js +128 -1
……@@ -10,7 +10,10 @@ const {
1010 deriveKeyAndLink,
1111 buildSubmissionManifest,
1212 manageRequest,
13 communityBeeEntry
13 communityBeeEntry,
14 buildReviewReport,
15 reviewEvidenceMatches,
16 MAX_INDEX_BYTES
1417} = communitySubmit
1518
1619// A real 64-hex content key + a known z-base-32 encoding of the SAME bytes, so
……@@ -143,3 +146,127 @@ test('communityBeeEntry: maps a manifest to the app!<id> schema', () => {
143146 assert.equal(value.publishedAt, 42) // carried from submittedAt
144147 assert.equal(value.name, 'Entry App')
145148})
149
150test('buildReviewReport: eligible web content still requires human acknowledgement', () => {
151 const report = buildReviewReport({
152 appKey: HEX,
153 pending: { appKey: HEX, publisherPubkey: 'b'.repeat(64), discoveredAt: 1000 },
154 manifest: { name: 'Reviewed App', driveKey: HEX, type: 'hypersite', version: '1.0.0' },
155 indexText: '<!doctype html><html><body>Hello</body></html>',
156 indexBytes: 47,
157 driveVersion: 4,
158 duplicates: []
159 }, 2000)
160
161 assert.equal(report.approvalAllowed, true)
162 assert.equal(report.requiresAcknowledgement, true)
163 assert.equal(report.summary.block, 0)
164 assert.ok(report.summary.warning >= 3)
165 assert.equal(report.previewUrl, 'hyper://' + HEX + '/')
166 assert.equal(report.manifest.name, 'Reviewed App')
167 assert.equal(report.manifest.driveKey, HEX)
168 assert.equal(report.manifest.link, 'hyper://' + HEX + '/')
169 assert.ok(report.checks.some((check) => check.id === 'publisher-identity' && check.status === 'warning'))
170 assert.ok(report.checks.some((check) => check.id === 'mutable-content' && check.status === 'warning'))
171 assert.ok(report.checks.some((check) => check.id === 'human-preview' && check.status === 'warning'))
172})
173
174test('buildReviewReport: missing manifest or entrypoint blocks approval', () => {
175 const report = buildReviewReport({
176 appKey: HEX,
177 pending: { appKey: HEX, publisherPubkey: 'b'.repeat(64) },
178 driveVersion: 1,
179 indexBytes: 0,
180 manifest: null
181 })
182
183 assert.equal(report.approvalAllowed, false)
184 assert.ok(report.summary.block >= 2)
185 assert.ok(report.checks.some((check) => check.id === 'entrypoint' && check.status === 'block'))
186 assert.ok(report.checks.some((check) => check.id === 'manifest' && check.status === 'block'))
187})
188
189test('buildReviewReport: native delivery and mismatched targets are blockers', () => {
190 const OTHER = 'a'.repeat(64)
191 const native = buildReviewReport({
192 appKey: HEX,
193 pending: { appKey: HEX, publisherPubkey: 'b'.repeat(64) },
194 driveVersion: 1,
195 indexText: '<html><body>native</body></html>',
196 manifest: { name: 'Native App', type: 'standalone', pearLink: 'pear://example' }
197 })
198 assert.equal(native.approvalAllowed, false)
199 assert.ok(native.checks.some((check) => check.id === 'delivery-boundary' && check.status === 'block'))
200
201 const mismatch = buildReviewReport({
202 appKey: HEX,
203 pending: { appKey: HEX, publisherPubkey: 'b'.repeat(64) },
204 driveVersion: 1,
205 indexText: '<html><body>site</body></html>',
206 manifest: { name: 'Wrong Target', link: 'hyper://' + OTHER + '/' }
207 })
208 assert.equal(mismatch.approvalAllowed, false)
209 assert.match(mismatch.checks.find((check) => check.id === 'delivery-boundary').detail, /does not match/i)
210
211 const invalid = buildReviewReport({
212 appKey: HEX,
213 pending: { appKey: HEX, publisherPubkey: 'b'.repeat(64) },
214 driveVersion: 1,
215 indexText: '<html><body>site</body></html>',
216 manifest: { name: 'Clearnet Target', link: 'https://example.com/app' }
217 })
218 assert.equal(invalid.approvalAllowed, false)
219 assert.match(invalid.checks.find((check) => check.id === 'delivery-boundary').detail, /not a valid browsable Hyperdrive/i)
220})
221
222test('buildReviewReport: injected key normalization accepts a matching z32 manifest target', () => {
223 const report = buildReviewReport({
224 appKey: HEX,
225 pending: { appKey: HEX, publisherPubkey: 'b'.repeat(64) },
226 driveVersion: 1,
227 indexText: '<html><body>site</body></html>',
228 manifest: { name: 'Z32 Target', link: 'hyper://' + Z32 + '/' },
229 normalizeKey: fakeNormalize
230 })
231 assert.equal(report.approvalAllowed, true)
232 assert.equal(report.checks.find((check) => check.id === 'delivery-boundary').status, 'pass')
233})
234
235test('buildReviewReport: duplicates and external behavior are review warnings', () => {
236 const report = buildReviewReport({
237 appKey: HEX,
238 pending: { appKey: HEX, publisherPubkey: 'b'.repeat(64) },
239 driveVersion: 2,
240 indexText: '<html><script src="https://cdn.example/app.js"></script><form></form></html>',
241 manifest: { name: 'External App', driveKey: HEX },
242 duplicates: [{ id: 'existing', name: 'Existing App', catalogName: 'PearBrowser Network', driveKey: HEX }]
243 })
244
245 assert.equal(report.approvalAllowed, true)
246 assert.ok(report.checks.some((check) => check.id === 'duplicate' && check.status === 'warning'))
247 assert.ok(report.checks.some((check) => check.id === 'page-behavior' && check.status === 'warning'))
248 assert.deepEqual(report.evidence.externalOrigins, ['https://cdn.example'])
249 assert.deepEqual(report.evidence.behaviorSignals, ['form submission'])
250})
251
252test('buildReviewReport: oversized index.html is a blocker', () => {
253 const report = buildReviewReport({
254 appKey: HEX,
255 pending: { appKey: HEX, publisherPubkey: 'b'.repeat(64) },
256 driveVersion: 1,
257 indexText: '<html></html>',
258 indexBytes: MAX_INDEX_BYTES + 1,
259 manifest: { name: 'Too Large', driveKey: HEX }
260 })
261 assert.equal(report.approvalAllowed, false)
262 assert.ok(report.checks.some((check) => check.id === 'entrypoint' && check.status === 'block'))
263})
264
265test('reviewEvidenceMatches binds approval to the reviewed drive version', () => {
266 const report = { checkedAt: 1234, evidence: { driveVersion: 9 } }
267 assert.equal(reviewEvidenceMatches({ reviewedAt: 1234, reviewedDriveVersion: 9 }, report), true)
268 assert.equal(reviewEvidenceMatches({ reviewedAt: 1234, reviewedDriveVersion: 10 }, report), false)
269 assert.equal(reviewEvidenceMatches({ reviewedAt: 1235, reviewedDriveVersion: 9 }, report), false)
270 assert.equal(reviewEvidenceMatches({}, report), false)
271 assert.equal(reviewEvidenceMatches({ reviewedAt: 1234, reviewedDriveVersion: 9 }, null), false)
272})
ui/boot.js +1 -0
……@@ -143,6 +143,7 @@ const C = {
143143 CMD_MOD_PENDING: 211,
144144 CMD_MOD_APPROVE: 212,
145145 CMD_MOD_REJECT: 213,
146 CMD_MOD_REVIEW: 214,
146147 CMD_ASK_BROWSER_CAPABILITIES: 220,
147148 CMD_ASK_BROWSER_START: 221,
148149 CMD_ASK_BROWSER_CANCEL: 222,
ui/shell.js +149 -27
……@@ -2481,9 +2481,9 @@ function CollaborativeCatalog ({ rpc, C }) {
24812481}
24822482
24832483// "Submit your app" — anyone can propose an app for the COMMUNITY catalogue.
2484// CMD_SUBMIT_APP publishes a manifest drive + seeds the app drive via HiveRelay;
2485// the relay queues a pin request in `review` mode. The app appears in everyone's
2486// Community list once a moderator approves it (ModeratorPanel below).
2484// CMD_SUBMIT_APP publishes a submission receipt + seeds the app drive via
2485// HiveRelay; the relay queues a pin request in `review` mode. Relay approval and
2486// Community-catalogue publication are separate operator gates.
24872487function CommunitySubmit ({ rpc, C }) {
24882488 const [name, setName] = useState('')
24892489 const [link, setLink] = useState('')
……@@ -2505,7 +2505,12 @@ function CommunitySubmit ({ rpc, C }) {
25052505 name: name.trim(), link: link.trim(), description: description.trim(),
25062506 author: author.trim(), categories
25072507 }, 90000)
2508 setOk(`Submitted "${(res && res.manifest && res.manifest.name) || name.trim()}" for review — it joins the Community list once a moderator approves it.`)
2508 const submittedName = (res && res.manifest && res.manifest.name) || name.trim()
2509 const relayStatus = res && res.status === 'pending-review'
2510 ? `${res.acceptances} relay${res.acceptances === 1 ? '' : 's'} acknowledged the review request.`
2511 : 'The request was broadcast, but no relay acknowledged it within the initial window; the client will retry and it is not yet confirmed in a review queue.'
2512 const receiptStatus = res && res.receiptWarning ? ` ${res.receiptWarning}` : ''
2513 setOk(`Submitted "${submittedName}". ${relayStatus}${receiptStatus} Catalogue publication remains a separate final gate.`)
25092514 setName(''); setLink(''); setDescription(''); setAuthor(''); setCategories('')
25102515 } catch (e) { setErr((e && e.message) || String(e)) } finally { setBusy(false) }
25112516 }
……@@ -2513,7 +2518,7 @@ function CommunitySubmit ({ rpc, C }) {
25132518 return html`
25142519 <div className="community-submit">
25152520 <h2>Submit your app <span className="settings-subtle">→ Community list</span></h2>
2516 <p className="subtitle">Add browsable Hyperdrive content to the community catalogue. HiveRelay replicates content; it never attests or delivers native executable code.</p>
2521 <p className="subtitle">Add browsable Hyperdrive content to the community review queue. The drive should contain <code>/index.html</code> and a truthful <code>/manifest.json</code>. HiveRelay replication is not an endorsement, and native executable targets are rejected.</p>
25172522 <div className="settings-card">
25182523 ${err && html`<div className="apps-error">${err}</div>`}
25192524 ${ok && html`<div className="apps-ok">${ok}</div>`}
……@@ -2554,17 +2559,22 @@ function CommunitySubmit ({ rpc, C }) {
25542559}
25552560
25562561// In-app moderator panel — operator-gated (needs the relay management URL + API
2557// key, saved to userdata settings). Lists the relay's pending pin-requests
2558// (CMD_MOD_PENDING) and approves/rejects them (CMD_MOD_APPROVE / CMD_MOD_REJECT).
2559// Collapsed by default since only the operator needs it. The relay must run in
2560// `review` accept mode for submissions to queue here.
2561function ModeratorPanel ({ rpc, C }) {
2562// key, saved to userdata settings). Queue rows must pass content-drive checks
2563// and receive a human preview acknowledgement before approval. Relay approval
2564// only authorizes replication; publishing the Community catalogue remains a
2565// separate operator-controlled step.
2566function ModeratorPanel ({ rpc, C, onPreview }) {
25622567 const [open, setOpen] = useState(false)
25632568 const [url, setUrl] = useState('')
25642569 const [apiKey, setApiKey] = useState('')
25652570 const [saved, setSaved] = useState(false)
25662571 const [pending, setPending] = useState(null) // null = not loaded yet
25672572 const [mode, setMode] = useState(null)
2573 const [reports, setReports] = useState({})
2574 const [acknowledged, setAcknowledged] = useState({})
2575 const [notes, setNotes] = useState({})
2576 const [reasons, setReasons] = useState({})
2577 const [audit, setAudit] = useState([])
25682578 const [busy, setBusy] = useState(null)
25692579 const [err, setErr] = useState('')
25702580 const [notice, setNotice] = useState('')
……@@ -2577,7 +2587,7 @@ function ModeratorPanel ({ rpc, C }) {
25772587 }).catch(() => {})
25782588 }, [])
25792589
2580 const flash = (m) => { setNotice(m); setTimeout(() => setNotice(''), 1800) }
2590 const flash = (m) => { setNotice(m); setTimeout(() => setNotice(''), 3500) }
25812591
25822592 const saveConfig = async () => {
25832593 setErr('')
……@@ -2591,19 +2601,62 @@ function ModeratorPanel ({ rpc, C }) {
25912601 setErr(''); setBusy('load')
25922602 try {
25932603 const res = await rpc.request(C.CMD_MOD_PENDING, {}, 30000)
2594 setPending(res.pending || []); setMode(res.mode || null)
2604 setPending(res.pending || []); setMode(res.mode || null); setAudit(res.audit || [])
2605 setReports({}); setAcknowledged({})
25952606 } catch (e) { setErr(e.message); setPending([]) } finally { setBusy(null) }
25962607 }
25972608
2598 const decide = async (appKey, approve) => {
2609 const runReview = async (request, force = false) => {
2610 const appKey = request.appKey
2611 setErr(''); setBusy('v:' + appKey)
2612 try {
2613 const report = await rpc.request(C.CMD_MOD_REVIEW, {
2614 appKey,
2615 publisherPubkey: request.publisherPubkey,
2616 force
2617 }, 45000)
2618 setReports((current) => ({ ...current, [appKey]: report }))
2619 setAcknowledged((current) => ({ ...current, [appKey]: false }))
2620 } catch (e) { setErr(e.message) } finally { setBusy(null) }
2621 }
2622
2623 const decide = async (request, approve) => {
2624 const appKey = request.appKey
2625 const report = reports[appKey]
2626 const reason = (reasons[appKey] || '').trim()
2627 const note = (notes[appKey] || '').trim()
2628 if (approve && !report) { setErr('Run due diligence before approving.'); return }
2629 if (approve && !note) { setErr('Record what you checked in the reviewer note before approving.'); return }
2630 if (!approve && !reason) { setErr('Add a rejection reason before rejecting.'); return }
25992631 setErr(''); setBusy((approve ? 'a:' : 'r:') + appKey)
26002632 try {
2601 await rpc.request(approve ? C.CMD_MOD_APPROVE : C.CMD_MOD_REJECT, { appKey }, 60000)
2633 const res = await rpc.request(approve ? C.CMD_MOD_APPROVE : C.CMD_MOD_REJECT, {
2634 appKey,
2635 acknowledged: acknowledged[appKey] === true,
2636 reviewedAt: report && report.checkedAt,
2637 reviewedDriveVersion: report && report.evidence && report.evidence.driveVersion,
2638 note,
2639 reason
2640 }, 60000)
26022641 setPending((list) => (list || []).filter((p) => p.appKey !== appKey))
2603 flash(approve ? 'Approved + pinned.' : 'Rejected.')
2642 if (res && res.audit) setAudit((current) => [res.audit, ...current].slice(0, 50))
2643 setReports((current) => { const next = { ...current }; delete next[appKey]; return next })
2644 flash(res && res.auditWarning
2645 ? res.auditWarning
2646 : (approve
2647 ? (res && res.promoted && res.promoted.deferred
2648 ? 'Relay pin approved. Community catalogue publication is still pending.'
2649 : 'Relay pin approved and audited.')
2650 : 'Rejected with an audit reason.'))
26042651 } catch (e) { setErr(e.message) } finally { setBusy(null) }
26052652 }
26062653
2654 const statusLabel = (report) => {
2655 if (!report) return 'Not checked'
2656 if (!report.approvalAllowed) return 'Blocked'
2657 return 'Needs human review'
2658 }
2659
26072660 return html`
26082661 <div className="moderator-panel">
26092662 <h2>
……@@ -2614,7 +2667,13 @@ function ModeratorPanel ({ rpc, C }) {
26142667 <div className="settings-card">
26152668 ${err && html`<div className="apps-error">${err}</div>`}
26162669 ${notice && html`<div className="apps-ok">${notice}</div>`}
2617 <p className="subtitle">Review apps submitted to the Community list. Needs your relay's management URL + operator API key. The relay must run in <code>review</code> accept mode.</p>
2670 <p className="subtitle">Review signed seed requests before a relay stores their content. The relay must run in <code>review</code> mode. Approval authorizes replication only; publishing the approved metadata into the shared Community catalogue is a separate release step.</p>
2671 <div className="mod-process">
2672 <span><strong>1</strong> Queue</span><span>→</span>
2673 <span><strong>2</strong> Fetch evidence</span><span>→</span>
2674 <span><strong>3</strong> Preview + decide</span><span>→</span>
2675 <span><strong>4</strong> Publish catalogue</span>
2676 </div>
26182677 <div className="settings-row">
26192678 <div className="profile-field">
26202679 <div className="settings-label">Relay management URL</div>
……@@ -2630,25 +2689,88 @@ function ModeratorPanel ({ rpc, C }) {
26302689 </div>
26312690 <div className="settings-row">
26322691 <button className="btn primary" onClick=${loadPending} disabled=${busy === 'load' || !url.trim()}>${busy === 'load' ? 'Loading…' : 'Load pending'}</button>
2633 ${mode && html`<span className="settings-subtle">relay mode: ${mode}</span>`}
2692 ${mode && html`<span className=${'mod-mode ' + (mode === 'review' ? 'pass' : 'warning')}>relay mode: ${mode}</span>`}
2693 ${pending && html`<span className="settings-subtle">${pending.length} queued</span>`}
26342694 </div>
2695 ${mode && mode !== 'review' && html`<div className="apps-error">This relay is not in review mode. Queue decisions are unsafe until its acceptance policy is set to <code>review</code>.</div>`}
26352696 ${pending && pending.length === 0 && html`<div className="settings-subtle" style=${{ padding: '6px 0' }}>No pending submissions.</div>`}
26362697 ${pending && pending.length > 0 && html`
26372698 <div className="mod-pending">
2638 ${pending.map((p) => html`
2639 <div className="settings-row" key=${p.appKey}>
2640 <div style=${{ minWidth: 0 }}>
2641 <div className="settings-label" style=${{ fontFamily: 'monospace' }}>${(p.appKey || '').slice(0, 16)}…</div>
2642 <div className="settings-subtle">by ${(p.publisherPubkey || 'unknown').slice(0, 12)}…${p.currentRelays ? ` · ${p.currentRelays} relay(s)` : ''}</div>
2699 ${pending.map((p) => {
2700 const report = reports[p.appKey]
2701 const warningCount = report && report.summary ? report.summary.warning : 0
2702 const relayMeta = [p.source, p.contentType, p.privacyTier, p.storageClass, p.availabilityClass].filter(Boolean)
2703 return html`
2704 <div className="mod-review-card" key=${p.appKey}>
2705 <div className="mod-review-head">
2706 <div style=${{ minWidth: 0 }}>
2707 <div className="app-name">${(report && report.manifest && report.manifest.name) || p.name || p.appId || 'Unidentified app'}</div>
2708 <div className="mod-key">${p.appKey}</div>
2709 <div className="settings-subtle">publisher ${(p.publisherPubkey || 'unknown').slice(0, 16)}…${p.currentRelays ? ` · ${p.currentRelays} current relay(s)` : ''}${p.replicationFactor ? ` · requests ${p.replicationFactor}` : ''}</div>
2710 ${relayMeta.length > 0 && html`<div className="settings-subtle">relay metadata · ${relayMeta.join(' · ')}</div>`}
2711 ${p.discoveredAt && html`<div className="settings-subtle">queued ${new Date(p.discoveredAt).toLocaleString()}</div>`}
2712 </div>
2713 <span className=${'mod-mode ' + (!report ? '' : (report.approvalAllowed ? 'warning' : 'block'))}>${statusLabel(report)}</span>
26432714 </div>
2644 <div style=${{ display: 'flex', gap: '6px' }}>
2645 <button className="btn small primary" onClick=${() => decide(p.appKey, true)} disabled=${!!busy}>${busy === 'a:' + p.appKey ? '…' : 'Approve'}</button>
2646 <button className="btn small subtle" onClick=${() => decide(p.appKey, false)} disabled=${!!busy}>${busy === 'r:' + p.appKey ? '…' : 'Reject'}</button>
2715 <div className="mod-review-actions">
2716 <button className="btn small" onClick=${() => runReview(p, !!report)} disabled=${!!busy}>${busy === 'v:' + p.appKey ? 'Checking…' : (report ? 'Re-run checks' : 'Run due diligence')}</button>
2717 <button className="btn small subtle" onClick=${() => onPreview && onPreview(`hyper://${p.appKey}/`)} disabled=${!!busy}>Open preview</button>
2718 </div>
2719 ${report && html`
2720 <div className="mod-summary">
2721 <span className="mod-check pass">${report.summary.pass} pass</span>
2722 <span className="mod-check warning">${report.summary.warning} warning${report.summary.warning === 1 ? '' : 's'}</span>
2723 <span className="mod-check block">${report.summary.block} blocker${report.summary.block === 1 ? '' : 's'}</span>
2724 </div>
2725 ${report.manifest && html`
2726 <div className="mod-manifest">
2727 <strong>${report.manifest.name}</strong>${report.manifest.version ? ` · v${report.manifest.version}` : ''}${report.manifest.author ? ` · ${report.manifest.author}` : ''}
2728 ${report.manifest.description && html`<div>${report.manifest.description}</div>`}
2729 ${report.manifest.categories && report.manifest.categories.length > 0 && html`<div className="settings-subtle">${report.manifest.categories.join(' · ')}</div>`}
2730 </div>
2731 `}
2732 <div className="mod-checks">
2733 ${report.checks.map((check) => html`
2734 <div className=${'mod-check-row ' + check.status} key=${check.id}>
2735 <span className="mod-check-icon">${check.status === 'pass' ? '✓' : (check.status === 'block' ? '×' : '!')}</span>
2736 <div><strong>${check.label}</strong><div>${check.detail}</div></div>
2737 </div>
2738 `)}
2739 </div>
2740 <label className="mod-ack">
2741 <input type="checkbox" checked=${acknowledged[p.appKey] === true} onChange=${(e) => setAcknowledged((current) => ({ ...current, [p.appKey]: e.target.checked }))} />
2742 I opened the preview, reviewed the warnings, and understand that passing automated checks is not a safety endorsement.
2743 </label>
2744 `}
2745 <div className="profile-field">
2746 <div className="settings-label">Reviewer note <span className="settings-subtle">(required for approval)</span></div>
2747 <textarea className="profile-input mod-textarea" placeholder="What did you inspect? Record relevant provenance or caveats." value=${notes[p.appKey] || ''} onInput=${(e) => setNotes((current) => ({ ...current, [p.appKey]: e.target.value }))}></textarea>
2748 </div>
2749 <div className="profile-field">
2750 <div className="settings-label">Rejection reason</div>
2751 <input className="profile-input" placeholder="Required only when rejecting" value=${reasons[p.appKey] || ''} onInput=${(e) => setReasons((current) => ({ ...current, [p.appKey]: e.target.value }))} />
2752 </div>
2753 <div className="mod-decision-actions">
2754 <button className="btn small primary" onClick=${() => decide(p, true)} disabled=${!!busy || mode !== 'review' || !report || !report.approvalAllowed || !(notes[p.appKey] || '').trim() || (warningCount > 0 && acknowledged[p.appKey] !== true)}>${busy === 'a:' + p.appKey ? 'Approving…' : 'Approve relay pin'}</button>
2755 <button className="btn small subtle" onClick=${() => decide(p, false)} disabled=${!!busy || mode !== 'review' || !(reasons[p.appKey] || '').trim()}>${busy === 'r:' + p.appKey ? 'Rejecting…' : 'Reject with reason'}</button>
26472756 </div>
26482757 </div>
2649 `)}
2758 `})}
26502759 </div>
26512760 `}
2761 ${audit.length > 0 && html`
2762 <details className="mod-audit">
2763 <summary>Recent local decision audit · ${audit.length}</summary>
2764 ${audit.slice(0, 20).map((entry) => html`
2765 <div className="mod-audit-row" key=${entry.appKey + ':' + entry.decidedAt}>
2766 <span className=${'mod-mode ' + (entry.action === 'approve' ? 'pass' : 'block')}>${entry.action}</span>
2767 <code>${(entry.appKey || '').slice(0, 16)}…</code>
2768 <span>${entry.reason || entry.note || 'No note'}</span>
2769 <time>${entry.decidedAt ? new Date(entry.decidedAt).toLocaleString() : ''}</time>
2770 </div>
2771 `)}
2772 </details>
2773 `}
26522774 </div>
26532775 `}
26542776 </div>
……@@ -3731,7 +3853,7 @@ function Apps ({ rpc, C, onLaunch }) {
37313853
37323854 <${CollaborativeCatalog} rpc=${rpc} C=${C} />
37333855
3734 <${ModeratorPanel} rpc=${rpc} C=${C} />
3856 <${ModeratorPanel} rpc=${rpc} C=${C} onPreview=${onLaunch} />
37353857
37363858 ${detailApp && html`
37373859 <div onClick=${() => setDetailApp(null)} style=${{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '24px' }}>