| … | … | @@ -150,6 +150,7 @@ Three independent keypairs — BIP-39 identity, HiveRelay publisher key, Coresto |
| 150 | 150 | | [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. | |
| 151 | 151 | | [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. | |
| 152 | 152 | | [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. | | |
| 153 | 154 | | [Feature roadmap](./docs/P2P-BROWSER-FEATURE-ROADMAP.md) | Current shipped/next/parking-lot roadmap after the 2026 audit. | |
| 154 | 155 | |
| 155 | 156 | ## Develop |
| … | … | @@ -11,10 +11,12 @@ |
| 11 | 11 | // and this COMMUNITY bee (user-submitted, moderated). A submission publishes a |
| 12 | 12 | // manifest drive + seeds the app drive; the relay queues a pin request in |
| 13 | 13 | // `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. | |
| 16 | 16 | |
| 17 | 17 | const HEX64 = /^[0-9a-f]{64}$/i |
| 18 | const MAX_INDEX_BYTES = 2 * 1024 * 1024 | |
| 19 | const MAX_REVIEW_TEXT = 500 | |
| 18 | 20 | |
| 19 | 21 | /** Slug an arbitrary string into a stable, catalogue-safe app id. */ |
| 20 | 22 | function slugify (s) { |
| … | … | @@ -161,11 +163,216 @@ function communityBeeEntry (manifest, now = Date.now()) { |
| 161 | 163 | return { key: 'app!' + id, value: entry } |
| 162 | 164 | } |
| 163 | 165 | |
| 166 | function boundedText (value, max = MAX_REVIEW_TEXT) { | |
| 167 | return String(value == null ? '' : value).trim().slice(0, max) | |
| 168 | } | |
| 169 | ||
| 170 | function reviewCheck (id, label, status, detail) { | |
| 171 | return { id, label, status, detail: boundedText(detail, 1000) } | |
| 172 | } | |
| 173 | ||
| 174 | function 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 | ||
| 179 | function 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 | ||
| 190 | function 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 | ||
| 200 | function 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 | */ | |
| 216 | function 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 | ||
| 164 | 368 | module.exports = { |
| 165 | 369 | HEX64, |
| 370 | MAX_INDEX_BYTES, | |
| 166 | 371 | slugify, |
| 167 | 372 | deriveKeyAndLink, |
| 168 | 373 | buildSubmissionManifest, |
| 169 | 374 | manageRequest, |
| 170 | communityBeeEntry | |
| 375 | communityBeeEntry, | |
| 376 | buildReviewReport, | |
| 377 | reviewEvidenceMatches | |
| 171 | 378 | } |
| … | … | @@ -180,12 +180,14 @@ const CMD_NOSTR_QUERY = 192 // { filter } → { events } (NIP-01 filter o |
| 180 | 180 | |
| 181 | 181 | // Community app submission + moderation (2026-06-22). Anyone can submit their |
| 182 | 182 | // 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). | |
| 185 | const 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). | |
| 186 | const CMD_SUBMIT_APP = 210 // { name, link|driveKey, description?, author?, categories?, iconData? } → publish receipt + seed → { id, status:'pending-review'|'awaiting-relay' } | |
| 186 | 187 | const CMD_MOD_PENDING = 211 // → { pending: [...] } pulled from the relay review queue |
| 187 | const CMD_MOD_APPROVE = 212 // { id } → relay approve + write app into community bee + re-pin | |
| 188 | const CMD_MOD_REJECT = 213 // { id, reason? } → relay reject | |
| 188 | const CMD_MOD_APPROVE = 212 // { appKey, acknowledged, reviewedAt, reviewedDriveVersion, note } → guarded relay approval + local audit | |
| 189 | const CMD_MOD_REJECT = 213 // { appKey, reason, note? } → relay reject + local audit | |
| 190 | const CMD_MOD_REVIEW = 214 // { appKey } → fetch content + manifest and return due-diligence evidence | |
| 189 | 191 | |
| 190 | 192 | // Browser chrome-owned local AI. Unlike /api/ai/* these commands are not |
| 191 | 193 | // callable by Hyperdrive pages: only PearBrowser's renderer can use them. |
| … | … | @@ -332,7 +334,7 @@ module.exports = { |
| 332 | 334 | CMD_IDENTITY_BINDING_PUBLISH, CMD_IDENTITY_BINDING_RESOLVE, CMD_SEARCH_FEDERATED, |
| 333 | 335 | CMD_NOSTR_GET_IDENTITY, CMD_NOSTR_BIND, CMD_NOSTR_REVOKE, |
| 334 | 336 | 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, | |
| 336 | 338 | CMD_ASK_BROWSER_CAPABILITIES, CMD_ASK_BROWSER_START, CMD_ASK_BROWSER_CANCEL, |
| 337 | 339 | CMD_SHIELD_STATUS, CMD_SHIELD_LOAD_LIST, CMD_SHIELD_REMOVE_LIST, |
| 338 | 340 | CMD_SHIELD_SET_ALLOW, CMD_SHIELD_SET_STRICT, |
| … | … | @@ -846,11 +846,160 @@ rpc.handle(C.CMD_MYCATALOG_UPDATE_APP, async (data) => { |
| 846 | 846 | |
| 847 | 847 | // Operator's relay management endpoint + API key (set via the moderator panel, |
| 848 | 848 | // persisted in userdata settings). Empty until configured. |
| 849 | const moderationPendingByKey = new Map() | |
| 850 | const moderationReviewCache = new Map() | |
| 851 | let moderationPendingMode = null | |
| 852 | let moderationPendingLoadedAt = 0 | |
| 853 | const MODERATION_REVIEW_CACHE_MS = 5 * 60 * 1000 | |
| 854 | const MAX_REVIEW_INDEX_BYTES = communitySubmit.MAX_INDEX_BYTES | |
| 855 | const MAX_REVIEW_MANIFEST_BYTES = 256 * 1024 | |
| 856 | const MAX_RELAY_MANAGE_RESPONSE_BYTES = 2 * 1024 * 1024 | |
| 857 | ||
| 849 | 858 | async function getModSettings () { |
| 850 | 859 | try { |
| 851 | 860 | 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 | ||
| 869 | function 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 | ||
| 877 | function boundedReviewText (value, max = 1000) { | |
| 878 | return String(value == null ? '' : value).trim().slice(0, max) | |
| 879 | } | |
| 880 | ||
| 881 | async 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 | ||
| 898 | async 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 | ||
| 922 | async 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 | ||
| 932 | async 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 | |
| 854 | 1003 | } |
| 855 | 1004 | |
| 856 | 1005 | // Minimal JSON HTTP(S) round-trip for the relay management API. bare-http1 has |
| … | … | @@ -858,12 +1007,20 @@ async function getModSettings () { |
| 858 | 1007 | // { status, json, text }. |
| 859 | 1008 | function relayManageHttp (spec, timeoutMs = 15000) { |
| 860 | 1009 | 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 | } | |
| 861 | 1018 | 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)) } | |
| 863 | 1020 | const isHttps = u.protocol === 'https:' |
| 864 | 1021 | let lib |
| 865 | 1022 | 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))) | |
| 867 | 1024 | } |
| 868 | 1025 | const req = lib.request({ |
| 869 | 1026 | method: spec.method || 'GET', |
| … | … | @@ -873,18 +1030,30 @@ function relayManageHttp (spec, timeoutMs = 15000) { |
| 873 | 1030 | headers: spec.headers || {} |
| 874 | 1031 | }, (res) => { |
| 875 | 1032 | 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 | }) | |
| 877 | 1044 | res.on('end', () => { |
| 1045 | if (settled) return | |
| 1046 | settled = true | |
| 878 | 1047 | clearTimeout(timer) |
| 879 | 1048 | const text = Buffer.concat(chunks).toString('utf8') |
| 880 | 1049 | let json = null |
| 881 | 1050 | try { json = text ? JSON.parse(text) : null } catch {} |
| 882 | 1051 | resolve({ status: res.statusCode, json, text }) |
| 883 | 1052 | }) |
| 884 | res.on('error', (err) => { clearTimeout(timer); reject(err) }) | |
| 1053 | res.on('error', fail) | |
| 885 | 1054 | }) |
| 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) | |
| 888 | 1057 | if (spec.body) req.write(spec.body) |
| 889 | 1058 | req.end() |
| 890 | 1059 | }) |
| … | … | @@ -926,6 +1095,7 @@ rpc.handle(C.CMD_SUBMIT_APP, async (data = {}) => { |
| 926 | 1095 | // 1) Publish the submission manifest drive (durable metadata: name/desc/icon) |
| 927 | 1096 | // so the moderator + mobile firehose can read it later. seed:true pins it. |
| 928 | 1097 | let manifestKey = null |
| 1098 | let receiptWarning = '' | |
| 929 | 1099 | try { |
| 930 | 1100 | const mdrive = await hiveRelay.publish( |
| 931 | 1101 | [{ path: '/manifest.json', content: JSON.stringify(manifest, null, 2) }], |
| … | … | @@ -936,7 +1106,9 @@ rpc.handle(C.CMD_SUBMIT_APP, async (data = {}) => { |
| 936 | 1106 | } |
| 937 | 1107 | } catch (err) { |
| 938 | 1108 | console.error('[submit] publish manifest failed:', err && err.message) |
| 1109 | receiptWarning = 'The submission receipt could not be published yet.' | |
| 939 | 1110 | } |
| 1111 | if (!manifestKey && !receiptWarning) receiptWarning = 'The submission receipt publisher returned no drive key.' | |
| 940 | 1112 | |
| 941 | 1113 | // 2) Seed the app's CONTENT drive. In `review` mode this queues a pin request |
| 942 | 1114 | // in the relay's pending queue for the moderator to approve. |
| … | … | @@ -949,12 +1121,22 @@ rpc.handle(C.CMD_SUBMIT_APP, async (data = {}) => { |
| 949 | 1121 | } |
| 950 | 1122 | |
| 951 | 1123 | 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 | } | |
| 953 | 1135 | }) |
| 954 | 1136 | |
| 955 | 1137 | rpc.handle(C.CMD_MOD_PENDING, async () => { |
| 956 | 1138 | await whenReady() |
| 957 | const { baseUrl, apiKey } = await getModSettings() | |
| 1139 | const { baseUrl, apiKey, audit } = await getModSettings() | |
| 958 | 1140 | const spec = communitySubmit.manageRequest('pending', { baseUrl, apiKey }) |
| 959 | 1141 | if (spec.error) throw new Error(spec.error) |
| 960 | 1142 | const res = await relayManageHttp(spec) |
| … | … | @@ -963,45 +1145,120 @@ rpc.handle(C.CMD_MOD_PENDING, async () => { |
| 963 | 1145 | const requests = Array.isArray(res.json.requests) ? res.json.requests : [] |
| 964 | 1146 | const toHex = (v) => typeof v === 'string' ? v : (v ? Buffer.from(v.data || v).toString('hex') : '') |
| 965 | 1147 | const pending = requests.map((r) => ({ |
| 966 | appKey: toHex(r.appKey), | |
| 1148 | appKey: normalizeDriveKey(toHex(r.appKey)), | |
| 967 | 1149 | publisherPubkey: toHex(r.publisherPubkey), |
| 968 | 1150 | discoveredAt: r.discoveredAt || null, |
| 969 | 1151 | 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 | |
| 971 | 1165 | })) |
| 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 | ||
| 1174 | rpc.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 }) | |
| 973 | 1180 | }) |
| 974 | 1181 | |
| 975 | 1182 | rpc.handle(C.CMD_MOD_APPROVE, async (data = {}) => { |
| 976 | 1183 | await whenReady() |
| 977 | 1184 | 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.') | |
| 978 | 1207 | const { baseUrl, apiKey } = await getModSettings() |
| 979 | 1208 | const spec = communitySubmit.manageRequest('approve', { baseUrl, apiKey, appKey }) |
| 980 | 1209 | if (spec.error) throw new Error(spec.error) |
| 981 | 1210 | const res = await relayManageHttp(spec) |
| 982 | 1211 | if (res.status === 401) throw new Error('Relay rejected the moderator API key (401).') |
| 983 | 1212 | 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. | |
| 985 | 1215 | 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) { | |
| 988 | 1218 | console.error('[mod] community-bee write failed:', err && err.message) |
| 989 | 1219 | promoted = { ok: false, error: err && err.message } |
| 990 | 1220 | } |
| 991 | 1221 | } |
| 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 } | |
| 993 | 1233 | }) |
| 994 | 1234 | |
| 995 | 1235 | rpc.handle(C.CMD_MOD_REJECT, async (data = {}) => { |
| 996 | 1236 | await whenReady() |
| 997 | 1237 | 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.') | |
| 998 | 1244 | const { baseUrl, apiKey } = await getModSettings() |
| 999 | 1245 | const spec = communitySubmit.manageRequest('reject', { baseUrl, apiKey, appKey }) |
| 1000 | 1246 | if (spec.error) throw new Error(spec.error) |
| 1001 | 1247 | const res = await relayManageHttp(spec) |
| 1002 | 1248 | if (res.status === 401) throw new Error('Relay rejected the moderator API key (401).') |
| 1003 | 1249 | 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 } | |
| 1005 | 1262 | }) |
| 1006 | 1263 | |
| 1007 | 1264 | // Collaborative (Autobee) catalog authoring — Rollout Phase 3. All gated by |
| … | … | @@ -0,0 +1,82 @@ |
| 1 | # Community catalogue review process | |
| 2 | ||
| 3 | PearBrowser separates content replication from catalogue endorsement. A relay | |
| 4 | pin approval means a relay may store and serve a drive. It does **not** by | |
| 5 | itself publish that drive into the shared Community catalogue. | |
| 6 | ||
| 7 | ## Submission lifecycle | |
| 8 | ||
| 9 | 1. A publisher opens **Apps → Submit your app**, supplies a browsable | |
| 10 | `hyper://` target, and submits it. | |
| 11 | 2. PearBrowser publishes a bounded submission receipt and sends a signed seed | |
| 12 | request for the content drive. | |
| 13 | 3. A HiveRelay running in `review` acceptance mode adds that request to its | |
| 14 | authenticated management queue. | |
| 15 | 4. An operator opens **Apps → Moderator tools**, loads the queue, and runs due | |
| 16 | diligence for one request at a time. | |
| 17 | 5. The operator previews the site, records a note, and either approves the | |
| 18 | relay pin or rejects the request with a reason. | |
| 19 | 6. 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 | ||
| 23 | A broadcast with zero initial relay acknowledgements is reported as | |
| 24 | `awaiting-relay`, not as a confirmed pending review. The client may retry the | |
| 25 | signed seed request, but an operator must see it in a relay queue before any | |
| 26 | decision is possible. | |
| 27 | ||
| 28 | ## Automated due diligence | |
| 29 | ||
| 30 | The 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 | ||
| 42 | Missing content, a missing publisher key, an invalid/missing manifest, key | |
| 43 | mismatch, or an executable delivery declaration is a blocker. Duplicates, | |
| 44 | behavioral signals, mutable-drive updates, and the fact that a signing key does | |
| 45 | not prove a real-world identity are warnings. The page scan is bounded and does | |
| 46 | not inspect linked scripts. Automated checks never declare an app safe: every | |
| 47 | eligible approval still requires the operator to open the preview and | |
| 48 | explicitly acknowledge the warnings. | |
| 49 | ||
| 50 | ## Decision records | |
| 51 | ||
| 52 | Approvals and rejections are written to a bounded local audit trail containing | |
| 53 | the app key, publisher key, action, reviewer note or rejection reason, decision | |
| 54 | time, and automated-check counts. API credentials are never copied into audit | |
| 55 | records. | |
| 56 | ||
| 57 | Rejection requires a reason. Approval fails closed if evidence has blockers or | |
| 58 | the human-review acknowledgement or reviewer note is missing. Decisions also | |
| 59 | require 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 | |
| 61 | review report; if the content changes before the decision, the reviewer must | |
| 62 | fetch and inspect new evidence. File-size caps are checked from authenticated | |
| 63 | Hyperdrive metadata before content is downloaded. The relay management API | |
| 64 | receives only the app key because its current protocol does not carry reviewer | |
| 65 | notes; the detailed record stays in PearBrowser until a signed shared | |
| 66 | moderation 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. |
| … | … | @@ -2364,6 +2364,143 @@ p { color: var(--pear-color-muted); margin-bottom: 12px; } |
| 2364 | 2364 | .search-fed-toggle input { cursor: pointer; } |
| 2365 | 2365 | .search-fed-toggle .fed-status { color: var(--pear-color-link); font-style: italic; } |
| 2366 | 2366 | |
| 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 | ||
| 2367 | 2504 | /* Lighthouse Phase 2 — trusted-peers (contact invite exchange) panel. */ |
| 2368 | 2505 | .trusted-peers { margin: 0 0 16px; border: 1px solid var(--pear-color-line); border-radius: 8px; background: var(--pear-color-canvas); } |
| 2369 | 2506 | .trusted-peers summary { padding: 10px 12px; cursor: pointer; font-size: 13px; color: var(--pear-color-text); user-select: none; } |
| … | … | @@ -10,7 +10,10 @@ const { |
| 10 | 10 | deriveKeyAndLink, |
| 11 | 11 | buildSubmissionManifest, |
| 12 | 12 | manageRequest, |
| 13 | communityBeeEntry | |
| 13 | communityBeeEntry, | |
| 14 | buildReviewReport, | |
| 15 | reviewEvidenceMatches, | |
| 16 | MAX_INDEX_BYTES | |
| 14 | 17 | } = communitySubmit |
| 15 | 18 | |
| 16 | 19 | // 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', () => { |
| 143 | 146 | assert.equal(value.publishedAt, 42) // carried from submittedAt |
| 144 | 147 | assert.equal(value.name, 'Entry App') |
| 145 | 148 | }) |
| 149 | ||
| 150 | test('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 | ||
| 174 | test('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 | ||
| 189 | test('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 | ||
| 222 | test('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 | ||
| 235 | test('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 | ||
| 252 | test('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 | ||
| 265 | test('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 | }) |
| … | … | @@ -143,6 +143,7 @@ const C = { |
| 143 | 143 | CMD_MOD_PENDING: 211, |
| 144 | 144 | CMD_MOD_APPROVE: 212, |
| 145 | 145 | CMD_MOD_REJECT: 213, |
| 146 | CMD_MOD_REVIEW: 214, | |
| 146 | 147 | CMD_ASK_BROWSER_CAPABILITIES: 220, |
| 147 | 148 | CMD_ASK_BROWSER_START: 221, |
| 148 | 149 | CMD_ASK_BROWSER_CANCEL: 222, |
| … | … | @@ -2481,9 +2481,9 @@ function CollaborativeCatalog ({ rpc, C }) { |
| 2481 | 2481 | } |
| 2482 | 2482 | |
| 2483 | 2483 | // "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. | |
| 2487 | 2487 | function CommunitySubmit ({ rpc, C }) { |
| 2488 | 2488 | const [name, setName] = useState('') |
| 2489 | 2489 | const [link, setLink] = useState('') |
| … | … | @@ -2505,7 +2505,12 @@ function CommunitySubmit ({ rpc, C }) { |
| 2505 | 2505 | name: name.trim(), link: link.trim(), description: description.trim(), |
| 2506 | 2506 | author: author.trim(), categories |
| 2507 | 2507 | }, 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.`) | |
| 2509 | 2514 | setName(''); setLink(''); setDescription(''); setAuthor(''); setCategories('') |
| 2510 | 2515 | } catch (e) { setErr((e && e.message) || String(e)) } finally { setBusy(false) } |
| 2511 | 2516 | } |
| … | … | @@ -2513,7 +2518,7 @@ function CommunitySubmit ({ rpc, C }) { |
| 2513 | 2518 | return html` |
| 2514 | 2519 | <div className="community-submit"> |
| 2515 | 2520 | <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> | |
| 2517 | 2522 | <div className="settings-card"> |
| 2518 | 2523 | ${err && html`<div className="apps-error">${err}</div>`} |
| 2519 | 2524 | ${ok && html`<div className="apps-ok">${ok}</div>`} |
| … | … | @@ -2554,17 +2559,22 @@ function CommunitySubmit ({ rpc, C }) { |
| 2554 | 2559 | } |
| 2555 | 2560 | |
| 2556 | 2561 | // 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. | |
| 2561 | function 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. | |
| 2566 | function ModeratorPanel ({ rpc, C, onPreview }) { | |
| 2562 | 2567 | const [open, setOpen] = useState(false) |
| 2563 | 2568 | const [url, setUrl] = useState('') |
| 2564 | 2569 | const [apiKey, setApiKey] = useState('') |
| 2565 | 2570 | const [saved, setSaved] = useState(false) |
| 2566 | 2571 | const [pending, setPending] = useState(null) // null = not loaded yet |
| 2567 | 2572 | 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([]) | |
| 2568 | 2578 | const [busy, setBusy] = useState(null) |
| 2569 | 2579 | const [err, setErr] = useState('') |
| 2570 | 2580 | const [notice, setNotice] = useState('') |
| … | … | @@ -2577,7 +2587,7 @@ function ModeratorPanel ({ rpc, C }) { |
| 2577 | 2587 | }).catch(() => {}) |
| 2578 | 2588 | }, []) |
| 2579 | 2589 | |
| 2580 | const flash = (m) => { setNotice(m); setTimeout(() => setNotice(''), 1800) } | |
| 2590 | const flash = (m) => { setNotice(m); setTimeout(() => setNotice(''), 3500) } | |
| 2581 | 2591 | |
| 2582 | 2592 | const saveConfig = async () => { |
| 2583 | 2593 | setErr('') |
| … | … | @@ -2591,19 +2601,62 @@ function ModeratorPanel ({ rpc, C }) { |
| 2591 | 2601 | setErr(''); setBusy('load') |
| 2592 | 2602 | try { |
| 2593 | 2603 | 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({}) | |
| 2595 | 2606 | } catch (e) { setErr(e.message); setPending([]) } finally { setBusy(null) } |
| 2596 | 2607 | } |
| 2597 | 2608 | |
| 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 } | |
| 2599 | 2631 | setErr(''); setBusy((approve ? 'a:' : 'r:') + appKey) |
| 2600 | 2632 | 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) | |
| 2602 | 2641 | 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.')) | |
| 2604 | 2651 | } catch (e) { setErr(e.message) } finally { setBusy(null) } |
| 2605 | 2652 | } |
| 2606 | 2653 | |
| 2654 | const statusLabel = (report) => { | |
| 2655 | if (!report) return 'Not checked' | |
| 2656 | if (!report.approvalAllowed) return 'Blocked' | |
| 2657 | return 'Needs human review' | |
| 2658 | } | |
| 2659 | ||
| 2607 | 2660 | return html` |
| 2608 | 2661 | <div className="moderator-panel"> |
| 2609 | 2662 | <h2> |
| … | … | @@ -2614,7 +2667,13 @@ function ModeratorPanel ({ rpc, C }) { |
| 2614 | 2667 | <div className="settings-card"> |
| 2615 | 2668 | ${err && html`<div className="apps-error">${err}</div>`} |
| 2616 | 2669 | ${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> | |
| 2618 | 2677 | <div className="settings-row"> |
| 2619 | 2678 | <div className="profile-field"> |
| 2620 | 2679 | <div className="settings-label">Relay management URL</div> |
| … | … | @@ -2630,25 +2689,88 @@ function ModeratorPanel ({ rpc, C }) { |
| 2630 | 2689 | </div> |
| 2631 | 2690 | <div className="settings-row"> |
| 2632 | 2691 | <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>`} | |
| 2634 | 2694 | </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>`} | |
| 2635 | 2696 | ${pending && pending.length === 0 && html`<div className="settings-subtle" style=${{ padding: '6px 0' }}>No pending submissions.</div>`} |
| 2636 | 2697 | ${pending && pending.length > 0 && html` |
| 2637 | 2698 | <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> | |
| 2643 | 2714 | </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> | |
| 2647 | 2756 | </div> |
| 2648 | 2757 | </div> |
| 2649 | `)} | |
| 2758 | `})} | |
| 2650 | 2759 | </div> |
| 2651 | 2760 | `} |
| 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 | `} | |
| 2652 | 2774 | </div> |
| 2653 | 2775 | `} |
| 2654 | 2776 | </div> |
| … | … | @@ -3731,7 +3853,7 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3731 | 3853 | |
| 3732 | 3854 | <${CollaborativeCatalog} rpc=${rpc} C=${C} /> |
| 3733 | 3855 | |
| 3734 | <${ModeratorPanel} rpc=${rpc} C=${C} /> | |
| 3856 | <${ModeratorPanel} rpc=${rpc} C=${C} onPreview=${onLaunch} /> | |
| 3735 | 3857 | |
| 3736 | 3858 | ${detailApp && html` |
| 3737 | 3859 | <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' }}> |