OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop commits / 48a57e7

feat: release Pear v3 catalogue submissions
48a57e7ff51ced69991fff07511cb231ccdbd63f
by bigdestiny2 <33146784+bigdestiny2@users.noreply.github.com> · 2026-07-29T22:33:57.000Z

Changed files (24) +1167 -603

CHANGELOG.md +49 -0
……@@ -4,6 +4,55 @@
44> only to explain past releases; they are not current installation, launch, or
55> recovery instructions. Current native delivery requires a verified package.
66
7## v0.8.0 — 2026-07-29
8
9Pear v3 application delivery and catalogue submission release. PearBrowser now
10separates native package installation from browsable Hyper content end to end,
11from publisher submission through moderation, catalogue transport, and launch.
12
13### Added
14
15- A host-owned Pear v3 native app launcher using pinned `pear-install`, with an
16 explicit OS confirmation and strict package identity, product, target,
17 destination, upgrade, and single-GUI-artifact validation.
18- A Pear v3 catalogue submission path for canonical root production links,
19 released version, exact product name, supported platform targets, and a
20 publisher production-release attestation.
21- Optional PNG, JPEG, WebP, GIF, or safe SVG catalogue icons, bounded in both
22 the form and backend and checked against their actual byte signatures.
23- Native-delivery round-trip coverage for published Hyperbee catalogues and
24 schema-sheets offline seeds.
25
26### Changed
27
28- Submissions now queue one bounded signed catalogue receipt. Hyper sites keep
29 content seeding separate; native releases remain distributed by Pear and are
30 never pinned or executed by the moderation relay.
31- Moderation loads the receipt first, fetches Hyper content only when relevant,
32 checks duplicate native identities, and binds approval to both receipt and
33 optional content versions.
34- The submission UI defaults to Pear v3, explains the
35 `build → stage → provision / multisig` release boundary, and retains a clear
36 Hyper-site alternative.
37- Mobile catalogue consumers preserve valid nested `nativeDelivery` metadata
38 as desktop-only listings while allowing only `hyper://` rows to navigate.
39
40### Security / release boundary
41
42- Legacy top-level `pear://` and `file://` values remain migration records and
43 are never supplied to `PearRuntime.run()` or the mobile browser runtime.
44- PearBrowser's own retained upgrade identity remains migration-only until its
45 human-controlled v3 production provision/multisig evidence is complete.
46- macOS notarization, Windows public-trust signing, and mobile store releases
47 remain gated on production credentials; package-proof assets must be labelled
48 accordingly and include SHA-256 sidecars.
49
50### Verification
51
52- Desktop suite: `700/700`.
53- Mobile suite: `566/566`.
54- iOS unsigned device build and Android debug Kotlin compilation pass.
55
756## v0.7.1 — 2026-07-16
857
958Corrective desktop transport release. The backend RPC handlers were healthy,
README.md +19 -18
……@@ -4,21 +4,21 @@ A local-first peer-to-peer browser, app store, search engine, naming layer, Nost
44
55**No accounts. No DNS. Local-first data.** Sites are addressed by stable Hyperdrive keys and can be pinned on the [HiveRelay](https://github.com/bigdestiny2/P2P-Hiverelay) backbone. Native applications are installed from verified packages; a catalogue never turns a remote executable link into a runtime command.
66
7**Current release:** `v0.7.1` · stable Pear production length `92858`. The current hot-synced app adds private DuckDuckGo search on the browser-owned home tab: PearBrowser sends no search analytics and excludes submitted searches from the optional persistent visit log, while DuckDuckGo still receives the query and network address. The release also makes the renderer/backend RPC transport survive reload and live-update handoffs: pending calls fail immediately on disconnect, the per-launch authenticated socket reconnects during a bounded grace window, and the shell visibly resumes instead of reporting unrelated 30-second command timeouts. It retains all `v0.7.0` Content Shield, Pear Plugins, and P2P catalogue features. Catalogue version `9` advertises the release. See [docs/RELEASE_v0.7.1_OPERATOR.md](./docs/RELEASE_v0.7.1_OPERATOR.md) for the coordinated publish record.
7**Current release:** `v0.8.0`. PearBrowser now runs its backend through the embedded Pear v3 runtime, installs compatible native apps through a host-confirmed `pear-install` boundary, and keeps browsable `hyper://` content separate from native delivery. Catalogue submissions accept canonical provisioned Pear v3 production identities or Hyper sites, queue one signed review receipt, bind approval to the reviewed receipt/content versions, and support bounded validated icons. Private search, reconnect-safe RPC, Content Shield, Pear Plugins, and the local-first catalogue/search/naming stack remain included. Catalogue version `11` advertises the release metadata.
88
99**Current architecture:** start with [docs/ARCHITECTURE_AND_CAPABILITIES.md](./docs/ARCHITECTURE_AND_CAPABILITIES.md). The deeper catalogue/search/naming/Nostr audit is in [docs/DEEP_AUDIT_CATALOG_SEARCH_NAMING_NOSTR_2026-06-21.md](./docs/DEEP_AUDIT_CATALOG_SEARCH_NAMING_NOSTR_2026-06-21.md).
1010
1111## Install it
1212
1313Primary desktop distribution is now native GitHub release packages. The
14`v0.7.1` release targets macOS, Windows, and Linux assets with SHA-256 sidecars
14`v0.8.0` release targets macOS, Windows, and Linux assets with SHA-256 sidecars
1515and platform manifests. Download from the
16[`v0.7.1` release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.7.1),
16[`v0.8.0` release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.8.0),
1717follow the [native install guide](./docs/INSTALL_NATIVE_PACKAGES.md), or resolve
1818the recommended asset for your machine from a source checkout:
1919
2020```sh
21npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop
21npm run resolve:native-release -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop
2222```
2323
2424The current package targets match the `cmake-pear` appling toolchain:
……@@ -27,9 +27,10 @@ The current package targets match the `cmake-pear` appling toolchain:
2727- Windows: `PearBrowser-<version>-windows-x64.msix` now
2828- Linux: `PearBrowser-<version>-linux-x64.AppImage` now, distro packages such as `.deb` later if demand warrants them
2929
30The previous stable `pear://` key is a **legacy migration record**, not an
31install or launch instruction. PearBrowser never passes a remote link to
32`PearRuntime.run()`. Keep legacy data intact, install the native package for
30The retained PearBrowser `upgrade` key is a **migration record** until the v3
31production provision/multisig quorum is independently verified; it is not an
32install instruction in the catalogue. PearBrowser never passes a remote link
33to `PearRuntime.run()`. Keep legacy data intact, install the native package for
3334your platform, and use the migration guidance in
3435[docs/PEAR_V3_MIGRATION.md](./docs/PEAR_V3_MIGRATION.md).
3536
……@@ -222,29 +223,29 @@ human approval of clean-install, upgrade, rollback, and data-continuity proof.
222223
223224## Distribution
224225
225The `appling/` directory contains the multi-architecture native shell — Bare + CMake builds for macOS / Windows / Linux. GitHub release assets are produced by `.github/workflows/desktop-native-release.yml`, which builds the appling on hosted macOS, Windows, and Linux runners, collects the native artifacts, writes SHA-256 sidecars, and attaches them to the matching release tag. Run the workflow manually with tag `v0.7.1` and `source_ref` set to the release commit to produce or refresh the attached release assets.
226The `appling/` directory contains the multi-architecture native shell — Bare + CMake builds for macOS / Windows / Linux. GitHub release assets are produced by `.github/workflows/desktop-native-release.yml`, which builds the appling on hosted macOS, Windows, and Linux runners, collects the native artifacts, writes SHA-256 sidecars, and attaches them to the matching release tag. Run the workflow manually with tag `v0.8.0` and `source_ref` set to the release commit to produce or refresh the attached release assets.
226227
227228Current generated artifacts are `.app.zip` on macOS, `.msix` on Windows, and `.AppImage` on Linux. The workflow uses `npm ci --prefix appling`, so update `appling/package-lock.json` deliberately when the native wrapper toolchain changes.
228229
229230```sh
230npm run check:appling-release -- --tag v0.7.1
231npm run check:appling-release -- --tag v0.8.0
231232npm run check:linux-appimage-metadata
232npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop
233npm run -s generate:native-signing-secret-plan -- --repo bigdestiny2/pearbrowser-desktop --tag v0.7.1 --source-ref <release-commit>
234npm run -s generate:native-install-snippet -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop
235npm run -s generate:native-install-guide -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop
236npm run -s generate:native-install-smoke-plan -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit>
237npm run generate:package-manager-manifests -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --trust-mode package-proof
233npm run resolve:native-release -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop
234npm run -s generate:native-signing-secret-plan -- --repo bigdestiny2/pearbrowser-desktop --tag v0.8.0 --source-ref <release-commit>
235npm run -s generate:native-install-snippet -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop
236npm run -s generate:native-install-guide -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop
237npm run -s generate:native-install-smoke-plan -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit>
238npm run generate:package-manager-manifests -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop --trust-mode package-proof
238239npm run check:native-signing -- --require-public-trust --secret-source github --repo bigdestiny2/pearbrowser-desktop
239npm run check:public-trust-readiness -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github
240npm run -s generate:public-trust-operator-report -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github
240npm run check:public-trust-readiness -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github
241npm run -s generate:public-trust-operator-report -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github
241242npm run -s generate:release-evidence-handoff
242243cd appling
243244npm ci
244245npm run generate
245246npm run build
246247cd ..
247npm run package:appling -- --tag v0.7.1
248npm run package:appling -- --tag v0.8.0
248249```
249250
250251Code signing is per-platform:
appling/CMakeLists.txt +1 -1
……@@ -332,7 +332,7 @@ add_pear_appling(
332332 pearbrowser_desktop
333333 ID "tco5k7h38uoxatedp1wongdbhjxow1x7jiwm3t1i9cujbebhsbty"
334334 NAME "PearBrowser"
335 VERSION 0.7.1
335 VERSION 0.8.0
336336 AUTHOR "pearbrowser-desktop contributors"
337337 DESCRIPTION "P2P browser, app store, and publishing platform — no servers required."
338338
appling/assets/linux/io.github.bigdestiny2.pearbrowser.metainfo.xml +1 -1
……@@ -20,7 +20,7 @@
2020 </categories>
2121 <url type="homepage">https://github.com/bigdestiny2/pearbrowser-desktop</url>
2222 <releases>
23 <release version="0.7.1" date="2026-07-16"/>
23 <release version="0.8.0" date="2026-07-29"/>
2424 <release version="0.7.0" date="2026-07-16"/>
2525 <release version="0.6.1" date="2026-07-15"/>
2626 <release version="0.6.0" date="2026-07-13"/>
backend/catalogue-seed.js +1 -1
……@@ -28,7 +28,7 @@ module.exports = {
2828 "p2p"
2929 ],
3030 "description": "P2P browser, decentralized app store, and site publisher. macOS / Windows / Linux. Talks to peers directly over Hyperswarm. Pinned 24/7 on HiveRelay.",
31 "version": "0.7.1",
31 "version": "0.8.0",
3232 "homepage": "hyper://03f0060a35451cfb6b68ad1dda1b8474ebb43fd9100071ccf7d67679a83ebb4f/",
3333 "sourceUrl": "https://github.com/bigdestiny2/pearbrowser-desktop",
3434 "license": "MIT",
backend/community-submit.cjs +311 -207
……@@ -1,24 +1,25 @@
11'use strict'
22
3// Pure helpers for the community app-submission + moderation feature (2026-06-22).
4//
5// No I/O lives here — backend/index.js wires these to the HiveRelay client
6// (publish/seed), bare-http1/bare-https (relay management API) and the community
7// Hyperbee writer. Keeping the manifest projection + management-request shaping
8// pure means they're unit-tested without relays, a swarm or the network.
9//
10// Two lists exist in the store (see ui/shell.js): a CURATED bee (operator picks)
11// and this COMMUNITY bee (user-submitted, moderated). A submission publishes a
12// manifest drive + seeds the app drive; the relay queues a pin request in
13// `review` mode; the in-app moderator panel approves/rejects via the relay
14// management API. Publishing an approved entry into the shared community bee
15// remains a separate, secret-holding operator release step.
3// Pure helpers for Community catalogue submission and moderation. I/O lives in
4// backend/index.js; this module owns validation, receipt projection and review
5// policy so every submission path is deterministic and unit-testable.
166
177const HEX64 = /^[0-9a-f]{64}$/i
8const PEAR_KEY = /^[13-9a-km-uw-z]{52}$/
9const SAFE_PRODUCT_NAME = /^[a-zA-Z0-9][a-zA-Z0-9 ._()+@-]{0,119}$/
1810const MAX_INDEX_BYTES = 2 * 1024 * 1024
11const MAX_ICON_DATA_CHARS = 20000
1912const MAX_REVIEW_TEXT = 500
13const NATIVE_TARGETS = new Set([
14 'darwin-arm64',
15 'darwin-x64',
16 'linux-arm64',
17 'linux-x64',
18 'win32-arm64',
19 'win32-x64'
20])
21const ICON_MIMES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/svg+xml'])
2022
21/** Slug an arbitrary string into a stable, catalogue-safe app id. */
2223function slugify (s) {
2324 const out = String(s == null ? '' : s)
2425 .toLowerCase()
……@@ -29,105 +30,188 @@ function slugify (s) {
2930 return out || 'app'
3031}
3132
32/**
33 * From a single user-supplied Hyperdrive input (a hyper:// link or a bare key)
34 * derive the 64-hex content drive key (what gets seeded/pinned) and a
35 * browsable link. `normalizeKey` is injected (index.js passes its
36 * normalizeDriveKey, which z32-decodes 52-char keys) so this stays pure.
37 *
38 * Returns { driveKey, link, kind } or { error }.
39 */
33function boundedText (value, max = MAX_REVIEW_TEXT) {
34 return String(value == null ? '' : value).trim().slice(0, max)
35}
36
37function normalizePearInstallLink (value) {
38 const raw = String(value || '').trim()
39 let parsed
40 try { parsed = new URL(raw) } catch { return { error: 'Paste a valid pear:// release link.' } }
41 const key = String(parsed.hostname || '').toLowerCase()
42 if (parsed.protocol !== 'pear:' || !PEAR_KEY.test(key)) {
43 return { error: 'Pear v3 submissions need a canonical 52-character pear:// release key.' }
44 }
45 if ((parsed.pathname && parsed.pathname !== '/') || parsed.search || parsed.hash || parsed.username || parsed.password || parsed.port) {
46 return { error: 'Use the root Pear release link without a version, path, query, fragment, credentials, or port.' }
47 }
48 return { installLink: `pear://${key}`, key }
49}
50
4051function deriveKeyAndLink (rawInput, normalizeKey = (x) => x) {
4152 const raw = String(rawInput == null ? '' : rawInput).trim()
4253 if (!raw) return { error: 'A hyper:// link or drive key is required.' }
54 if (/^(?:pear|file):\/\//i.test(raw)) {
55 return { error: 'Choose Pear v3 app and provide its canonical release link; executable links are not browsable Hyper content.' }
56 }
57 const token = raw.replace(/^hyper:\/\//i, '').split(/[/?#]/)[0]
58 const driveKey = String(normalizeKey(token) || '').toLowerCase()
59 if (!HEX64.test(driveKey)) {
60 return { error: 'Could not derive a 64-character drive key. Paste a hyper:// link or a 64-hex / z-base-32 key.' }
61 }
62 return { driveKey, link: `hyper://${driveKey}/`, kind: 'hyper' }
63}
4364
44 let driveKey = ''
45 let link = ''
46 let kind = ''
47 if (/^pear:\/\//i.test(raw) || /^file:\/\//i.test(raw)) {
48 return { error: 'Remote executable app links are not accepted. Submit browsable hyper:// content; native apps use verified package delivery.' }
49 } else {
50 const token = raw.replace(/^hyper:\/\//i, '').split(/[/?#]/)[0]
51 driveKey = String(normalizeKey(token) || '').toLowerCase()
52 link = 'hyper://' + driveKey + '/'
53 kind = 'hyper'
65function normalizeCategories (value) {
66 const source = Array.isArray(value)
67 ? value
68 : (typeof value === 'string' ? value.split(',') : [])
69 return [...new Set(source.map((item) => boundedText(item, 60)).filter(Boolean))].slice(0, 12)
70}
71
72function normalizeNativeTargets (value) {
73 const source = Array.isArray(value)
74 ? value
75 : (typeof value === 'string' ? value.split(',') : [])
76 const targets = [...new Set(source.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))]
77 const invalid = targets.filter((target) => !NATIVE_TARGETS.has(target))
78 if (invalid.length) return { error: `Unsupported Pear v3 target: ${invalid.join(', ')}.` }
79 if (!targets.length) return { error: 'Select at least one operating-system target included in the Pear release.' }
80 return { targets: targets.slice(0, 12) }
81}
82
83function hasExpectedIconSignature (mime, buffer) {
84 if (!buffer || buffer.length === 0) return false
85 if (mime === 'image/png') return buffer.length >= 8 && buffer.subarray(0, 8).toString('hex') === '89504e470d0a1a0a'
86 if (mime === 'image/jpeg') return buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff
87 if (mime === 'image/webp') return buffer.length >= 12 && buffer.subarray(0, 4).toString() === 'RIFF' && buffer.subarray(8, 12).toString() === 'WEBP'
88 if (mime === 'image/gif') return buffer.length >= 6 && /^GIF8[79]a$/.test(buffer.subarray(0, 6).toString())
89 if (mime === 'image/svg+xml') {
90 const svg = buffer.toString('utf8').trim()
91 return /^(?:<\?xml[^>]*>\s*)?<svg\b/i.test(svg) &&
92 !/<(?:script|foreignObject)\b/i.test(svg) &&
93 !/\son[a-z]+\s*=/i.test(svg) &&
94 !/(?:href|src)\s*=\s*["']\s*(?:javascript:|data:|https?:)/i.test(svg)
5495 }
96 return false
97}
5598
56 if (!HEX64.test(driveKey)) {
57 return { error: 'Could not derive a 64-character drive key from "' + raw + '". Paste a hyper:// link or a 64-hex / z-base-32 key.' }
99function normalizeIconData (value) {
100 if (value == null || value === '') return { iconData: '' }
101 if (typeof value !== 'string') return { error: 'The uploaded icon must be an image data URL.' }
102 if (value.length > MAX_ICON_DATA_CHARS) {
103 return { error: `The uploaded icon is too large. Keep the encoded icon under ${MAX_ICON_DATA_CHARS.toLocaleString()} characters.` }
58104 }
59 return { driveKey, link, kind }
105 const match = value.match(/^data:(image\/(?:png|jpeg|webp|gif|svg\+xml));base64,([A-Za-z0-9+/]+={0,2})$/i)
106 if (!match) return { error: 'Use a PNG, JPEG, WebP, GIF, or safe SVG icon.' }
107 const mime = match[1].toLowerCase()
108 if (!ICON_MIMES.has(mime)) return { error: 'Unsupported icon format.' }
109 let buffer
110 try { buffer = Buffer.from(match[2], 'base64') } catch { return { error: 'The uploaded icon is not valid base64 image data.' } }
111 if (!hasExpectedIconSignature(mime, buffer)) return { error: 'The uploaded icon bytes do not match the selected image format or contain unsafe SVG content.' }
112 return { iconData: `data:${mime};base64,${match[2]}` }
60113}
61114
62115/**
63 * Project a raw submit payload into the catalogue manifest the relay + bee use.
64 *
65 * input: { name, link|driveKey|key, description?, author?, categories?, version?, iconData?, id? }
66 * ctx: { submittedBy?, now?, normalizeKey? }
116 * Project a submit payload into a bounded catalogue receipt.
67117 *
68 * Returns { manifest, id, driveKey } or { error }.
118 * Hyper submissions reference separately seeded browsable content. Pear v3
119 * submissions reference a root release identity produced by build/stage and a
120 * provision or multisig production line. The receipt itself is what enters the
121 * relay review queue; native release bytes are distributed by Pear, not HiveRelay.
69122 */
70123function buildSubmissionManifest (input = {}, ctx = {}) {
71 const name = String(input.name == null ? '' : input.name).trim()
124 const name = boundedText(input.name, 160)
72125 if (!name) return { error: 'App name is required.' }
73
74 const derived = deriveKeyAndLink(
75 input.link || input.driveKey || input.key,
76 ctx.normalizeKey || ((x) => x)
77 )
78 if (derived.error) return { error: derived.error }
79
80 let categories = []
81 if (Array.isArray(input.categories)) {
82 categories = input.categories.map((c) => String(c).trim()).filter(Boolean).slice(0, 12)
83 } else if (typeof input.categories === 'string') {
84 categories = input.categories.split(',').map((c) => c.trim()).filter(Boolean).slice(0, 12)
85 }
126 const rawTarget = input.link || input.installLink || input.driveKey || input.key
127 const requestedKind = String(input.submissionKind || input.kind || '').trim().toLowerCase()
128 const kind = requestedKind || (/^pear:\/\//i.test(String(rawTarget || '').trim()) ? 'pear-v3' : 'hyper')
129 if (!['hyper', 'pear-v3'].includes(kind)) return { error: 'Choose either a Pear v3 app or browsable Hyper content.' }
86130
87131 const id = slugify(input.id || name)
88132 const now = Number.isFinite(ctx.now) ? ctx.now : Date.now()
89
90 const manifest = {
133 const version = boundedText(input.version, 40)
134 const categories = normalizeCategories(input.categories)
135 const icon = normalizeIconData(input.iconData)
136 if (icon.error) return { error: icon.error }
137 const common = {
138 receiptVersion: 1,
139 submissionKind: kind,
91140 id,
92 name: name.slice(0, 160),
93 description: String(input.description || '').slice(0, 4000),
94 driveKey: derived.driveKey,
95 link: derived.link,
96 version: String(input.version || '1.0.0').slice(0, 40),
97 author: String(input.author || '').slice(0, 160),
141 name,
142 description: boundedText(input.description, 4000),
143 version,
144 author: boundedText(input.author, 160),
98145 categories,
99 type: 'hypersite',
100146 list: 'community',
101147 submittedBy: ctx.submittedBy || null,
102148 submittedAt: now
103149 }
104 // Inline icon (data: URI) is optional; cap it so a submission can't bloat the
105 // manifest drive. ~200KB is generous for a 64x64 SVG/PNG data URI.
106 if (typeof input.iconData === 'string' && input.iconData && input.iconData.length <= 200000) {
107 manifest.iconData = input.iconData
150 if (icon.iconData) common.iconData = icon.iconData
151
152 if (kind === 'pear-v3') {
153 const release = normalizePearInstallLink(rawTarget)
154 if (release.error) return release
155 const productName = boundedText(input.productName || name, 120)
156 if (!SAFE_PRODUCT_NAME.test(productName)) return { error: 'Installed product name must match the Pear package and contain only ordinary filename-safe characters.' }
157 if (!version) return { error: 'Pear v3 submissions require the released package version.' }
158 const nativeTargets = normalizeNativeTargets(input.targets)
159 if (nativeTargets.error) return nativeTargets
160 if (input.releaseConfirmed !== true) {
161 return { error: 'Confirm that this root link is the currently seeded production provision or multisig release line.' }
162 }
163 const manifest = {
164 ...common,
165 type: 'standalone',
166 nativeDelivery: {
167 status: 'available',
168 kind: 'pear-v3',
169 installLink: release.installLink,
170 productName,
171 targets: nativeTargets.targets
172 },
173 release: {
174 channel: 'production',
175 publisherConfirmed: true,
176 workflow: 'pear-v3-build-stage-provision-multisig'
177 }
178 }
179 return {
180 manifest,
181 id,
182 kind,
183 installLink: release.installLink,
184 driveKey: null,
185 receiptId: `community-native-${release.key}`
186 }
187 }
188
189 const derived = deriveKeyAndLink(rawTarget, ctx.normalizeKey || ((x) => x))
190 if (derived.error) return derived
191 const manifest = {
192 ...common,
193 version: version || '1.0.0',
194 driveKey: derived.driveKey,
195 link: derived.link,
196 type: 'hypersite'
197 }
198 return {
199 manifest,
200 id,
201 kind,
202 driveKey: derived.driveKey,
203 installLink: null,
204 receiptId: `community-hyper-${derived.driveKey}`
108205 }
109 return { manifest, id, driveKey: derived.driveKey }
110206}
111207
112/**
113 * Shape a relay management API request for the moderator panel. Pure: returns a
114 * { url, method, headers, body? } spec (or { error }) that index.js feeds to
115 * its bare-http(s) helper. Auth is operator API key via Bearer.
116 *
117 * action: 'pending' | 'approve' | 'reject'
118 * opts: { baseUrl, apiKey?, appKey? }
119 */
120208function manageRequest (action, opts = {}) {
121209 const base = String(opts.baseUrl || '').trim().replace(/\/+$/, '')
122210 if (!base) return { error: 'Relay management URL is not configured. Set it in the Moderator panel.' }
123211 if (!/^https?:\/\//i.test(base)) return { error: 'Relay management URL must start with http:// or https://' }
124
125212 const headers = { 'content-type': 'application/json', accept: 'application/json' }
126213 if (opts.apiKey) headers.authorization = 'Bearer ' + String(opts.apiKey)
127
128 if (action === 'pending') {
129 return { url: base + '/api/manage/catalog/pending', method: 'GET', headers }
130 }
214 if (action === 'pending') return { url: base + '/api/manage/catalog/pending', method: 'GET', headers }
131215 if (action === 'approve' || action === 'reject') {
132216 const appKey = String(opts.appKey || '').toLowerCase()
133217 if (!HEX64.test(appKey)) return { error: action + ' needs a 64-hex appKey.' }
……@@ -141,41 +225,54 @@ function manageRequest (action, opts = {}) {
141225 return { error: 'Unknown moderation action: ' + action }
142226}
143227
144/**
145 * The (key, value) pair to write into the community Hyperbee for an approved
146 * app, matching scripts/lib/catalog-bee.js's `app!<id>` schema that
147 * backend/catalog-manager.js loadCatalogBee() reads back.
148 */
228function safeNativeDelivery (manifest) {
229 const source = manifest && manifest.nativeDelivery
230 if (!source || source.status !== 'available' || source.kind !== 'pear-v3') return null
231 const release = normalizePearInstallLink(source.installLink)
232 if (release.error) return null
233 const productName = boundedText(source.productName, 120)
234 if (!SAFE_PRODUCT_NAME.test(productName)) return null
235 const targetResult = normalizeNativeTargets(source.targets)
236 if (targetResult.error) return null
237 return {
238 status: 'available',
239 kind: 'pear-v3',
240 installLink: release.installLink,
241 productName,
242 targets: targetResult.targets
243 }
244}
245
149246function communityBeeEntry (manifest, now = Date.now()) {
150 const id = slugify(manifest.id || manifest.driveKey || manifest.link)
247 const nativeDelivery = safeNativeDelivery(manifest)
248 const id = slugify(manifest.id || manifest.driveKey || nativeDelivery?.installLink || manifest.link)
151249 const entry = {
152250 id,
153 name: manifest.name || id,
154 description: manifest.description || '',
155 driveKey: manifest.driveKey || '',
156 link: manifest.link || '',
157 version: manifest.version || '',
158 author: manifest.author || '',
159 categories: Array.isArray(manifest.categories) ? manifest.categories : [],
251 name: boundedText(manifest.name, 160) || id,
252 description: boundedText(manifest.description, 1000),
253 version: boundedText(manifest.version, 40),
254 author: boundedText(manifest.author, 160),
255 categories: normalizeCategories(manifest.categories),
256 type: nativeDelivery ? 'standalone' : 'hypersite',
160257 publishedAt: Number.isFinite(manifest.submittedAt) ? manifest.submittedAt : now
161258 }
162 if (typeof manifest.iconData === 'string' && manifest.iconData) entry.iconData = manifest.iconData
259 if (nativeDelivery) entry.nativeDelivery = nativeDelivery
260 else {
261 const driveKey = boundedText(manifest.driveKey, 64).toLowerCase()
262 if (HEX64.test(driveKey)) {
263 entry.driveKey = driveKey
264 entry.link = `hyper://${driveKey}/`
265 }
266 }
267 const icon = normalizeIconData(manifest.iconData)
268 if (icon.iconData) entry.iconData = icon.iconData
163269 return { key: 'app!' + id, value: entry }
164270}
165271
166function boundedText (value, max = MAX_REVIEW_TEXT) {
167 return String(value == null ? '' : value).trim().slice(0, max)
168}
169
170272function reviewCheck (id, label, status, detail) {
171273 return { id, label, status, detail: boundedText(detail, 1000) }
172274}
173275
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
179276function externalOriginsFromHtml (html) {
180277 const origins = new Set()
181278 const source = String(html || '').slice(0, MAX_INDEX_BYTES)
……@@ -193,7 +290,8 @@ function duplicateSummary (duplicates) {
193290 id: boundedText(entry && entry.id, 80),
194291 name: boundedText(entry && entry.name, 160),
195292 catalogName: boundedText(entry && (entry.catalogName || entry.source), 160),
196 driveKey: boundedText(entry && entry.driveKey, 64)
293 driveKey: boundedText(entry && entry.driveKey, 64),
294 nativeInstallLink: boundedText(entry && entry.nativeDelivery && entry.nativeDelivery.installLink, 80)
197295 }))
198296}
199297
……@@ -201,130 +299,138 @@ function reviewEvidenceMatches (input = {}, report = null) {
201299 if (!report || typeof report !== 'object') return false
202300 const evidence = report.evidence && typeof report.evidence === 'object' ? report.evidence : {}
203301 return Number(input.reviewedAt) === Number(report.checkedAt) &&
204 Number(input.reviewedDriveVersion) === Number(evidence.driveVersion) &&
302 Number(input.reviewedReceiptDriveVersion) === Number(evidence.receiptDriveVersion) &&
303 Number(input.reviewedTargetDriveVersion || 0) === Number(evidence.targetDriveVersion || 0) &&
205304 Number.isFinite(Number(report.checkedAt)) &&
206 Number.isFinite(Number(evidence.driveVersion))
305 Number.isFinite(Number(evidence.receiptDriveVersion))
207306}
208307
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 */
216308function buildReviewReport (input = {}, now = Date.now()) {
217309 const pending = input.pending && typeof input.pending === 'object' ? input.pending : {}
218 const appKey = boundedText(input.appKey || pending.appKey, 64).toLowerCase()
310 const receiptKey = boundedText(input.appKey || pending.appKey, 64).toLowerCase()
219311 const publisherPubkey = boundedText(pending.publisherPubkey, 64).toLowerCase()
220312 const manifest = input.manifest && typeof input.manifest === 'object' && !Array.isArray(input.manifest)
221313 ? input.manifest
222314 : null
315 const receiptDriveVersion = Number.isFinite(input.receiptDriveVersion) ? input.receiptDriveVersion : 0
316 const targetDriveVersion = Number.isFinite(input.targetDriveVersion) ? input.targetDriveVersion : 0
223317 const indexText = String(input.indexText || '').slice(0, MAX_INDEX_BYTES)
224318 const indexBytes = Number.isFinite(input.indexBytes) ? input.indexBytes : Buffer.byteLength(indexText)
225 const driveVersion = Number.isFinite(input.driveVersion) ? input.driveVersion : 0
226319 const duplicates = duplicateSummary(input.duplicates)
227320 const checks = []
228321
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
322 checks.push(HEX64.test(receiptKey)
323 ? reviewCheck('receipt-key', 'Submission receipt', 'pass', 'The relay queued a canonical 64-hex receipt drive key.')
324 : reviewCheck('receipt-key', 'Submission receipt', 'block', 'The queue entry does not contain a valid 64-hex receipt drive key.'))
233325 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
326 ? reviewCheck('publisher', 'Publisher signature', 'pass', 'The relay queue carries a 32-byte publisher public key and verifies the signed receipt seed request.')
327 : reviewCheck('publisher', 'Publisher signature', 'block', 'No canonical publisher public key is visible for the signed receipt request.'))
237328 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.'))
329 checks.push(reviewCheck('publisher-identity', 'Publisher identity', 'warning', 'The signing key authenticates this receipt request, not a real-world identity or every claim in the receipt. Verify provenance independently.'))
239330 }
240331
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.'))
332 if (input.receiptFetchError) {
333 checks.push(reviewCheck('receipt-availability', 'Receipt availability', 'block', 'The submission receipt could not be fetched: ' + boundedText(input.receiptFetchError, 700)))
334 } else if (receiptDriveVersion <= 0) {
335 checks.push(reviewCheck('receipt-availability', 'Receipt availability', 'block', 'No replicated receipt drive version was available for review.'))
245336 } 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.`))
337 checks.push(reviewCheck('receipt-availability', 'Receipt availability', 'pass', `Fetched receipt drive version ${receiptDriveVersion}.`))
258338 }
259339
260340 if (input.manifestError) {
261 checks.push(reviewCheck('manifest', 'App manifest', 'block', 'Invalid /manifest.json: ' + boundedText(input.manifestError, 700)))
341 checks.push(reviewCheck('manifest', 'Catalogue receipt', 'block', 'Invalid /manifest.json: ' + boundedText(input.manifestError, 700)))
262342 } 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.'))
343 checks.push(reviewCheck('manifest', 'Catalogue receipt', 'block', 'Missing /manifest.json on the queued receipt drive.'))
264344 } else if (!boundedText(manifest.name, 160)) {
265 checks.push(reviewCheck('manifest', 'App manifest', 'block', '/manifest.json is missing a non-empty name.'))
345 checks.push(reviewCheck('manifest', 'Catalogue receipt', 'block', 'The receipt is missing a non-empty app name.'))
346 } else if (manifest.receiptVersion !== 1) {
347 checks.push(reviewCheck('manifest', 'Catalogue receipt', 'block', 'The receipt version is missing or unsupported.'))
266348 } else {
267 checks.push(reviewCheck('manifest', 'App manifest', 'pass', `Manifest identifies “${boundedText(manifest.name, 160)}”.`))
349 checks.push(reviewCheck('manifest', 'Catalogue receipt', 'pass', `Receipt identifies “${boundedText(manifest.name, 160)}”.`))
268350 }
269351
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
352 const inferredKind = manifest && (manifest.submissionKind || (manifest.nativeDelivery ? 'pear-v3' : (manifest.driveKey ? 'hyper' : '')))
353 const kind = inferredKind === 'pear-v3' ? 'pear-v3' : (inferredKind === 'hyper' ? 'hyper' : '')
354 let previewUrl = ''
355 let projectedManifest = null
356 let externalOrigins = []
357 let behaviorSignals = []
358
359 if (manifest && kind === 'hyper') {
360 const target = deriveKeyAndLink(manifest.link || manifest.driveKey, input.normalizeKey || ((value) => value))
361 if (target.error) {
362 checks.push(reviewCheck('delivery-boundary', 'Hyper target', 'block', 'The receipt does not declare a valid browsable Hyperdrive target.'))
363 } else if (input.targetDriveKey && target.driveKey !== String(input.targetDriveKey).toLowerCase()) {
364 checks.push(reviewCheck('delivery-boundary', 'Hyper target', 'block', 'The reviewed content drive does not match the target declared by the receipt.'))
365 } else {
366 previewUrl = target.link
367 checks.push(reviewCheck('delivery-boundary', 'Hyper target', 'pass', 'The receipt points to browsable Hyper content and does not request native execution.'))
368 }
369
370 if (input.targetFetchError) {
371 checks.push(reviewCheck('target-availability', 'Content availability', 'block', 'The target content drive could not be fetched: ' + boundedText(input.targetFetchError, 700)))
372 } else if (targetDriveVersion <= 0) {
373 checks.push(reviewCheck('target-availability', 'Content availability', 'block', 'No replicated target drive version was available for review.'))
374 } else {
375 checks.push(reviewCheck('target-availability', 'Content availability', 'pass', `Fetched target drive version ${targetDriveVersion}.`))
376 checks.push(reviewCheck('mutable-content', 'Mutable content', 'warning', `This review covers target drive version ${targetDriveVersion}; later updates under the same key need re-review.`))
377 }
378
379 if (!indexBytes) checks.push(reviewCheck('entrypoint', 'Browser entrypoint', 'block', 'Missing or empty /index.html on the target content drive.'))
380 else if (indexBytes > MAX_INDEX_BYTES) checks.push(reviewCheck('entrypoint', 'Browser entrypoint', 'block', `/index.html is ${indexBytes} bytes; the review cap is ${MAX_INDEX_BYTES} bytes.`))
381 else if (!/<(?:!doctype\s+html|html|head|body)\b/i.test(indexText)) checks.push(reviewCheck('entrypoint', 'Browser entrypoint', 'warning', `/index.html is ${indexBytes} bytes but does not look like an HTML document.`))
382 else checks.push(reviewCheck('entrypoint', 'Browser entrypoint', 'pass', `/index.html is present (${indexBytes} bytes) and looks like HTML.`))
383
384 externalOrigins = externalOriginsFromHtml(indexText)
385 if (/\beval\s*\(|\bnew\s+Function\s*\(/i.test(indexText)) behaviorSignals.push('dynamic code execution')
386 if (/<iframe\b/i.test(indexText)) behaviorSignals.push('iframe embedding')
387 if (/<form\b/i.test(indexText)) behaviorSignals.push('form submission')
388 if (/\bserviceWorker\s*\.\s*register\s*\(/i.test(indexText)) behaviorSignals.push('service worker')
389 if (externalOrigins.length || behaviorSignals.length) {
390 const pieces = []
391 if (externalOrigins.length) pieces.push('external origins: ' + externalOrigins.join(', '))
392 if (behaviorSignals.length) pieces.push('behaviors: ' + behaviorSignals.join(', '))
393 checks.push(reviewCheck('page-behavior', 'Page behavior', 'warning', pieces.join(' · ') + '. Inspect the preview before approving.'))
394 } else {
395 checks.push(reviewCheck('page-behavior', 'Page behavior', 'pass', 'No external HTTP origins or high-signal dynamic behaviors were found in the bounded /index.html scan. Linked scripts were not inspected.'))
287396 }
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.'))
397 projectedManifest = communityBeeEntry(manifest, now).value
398 } else if (manifest && kind === 'pear-v3') {
399 const nativeDelivery = safeNativeDelivery(manifest)
400 if (!nativeDelivery) {
401 checks.push(reviewCheck('native-delivery', 'Pear v3 release identity', 'block', 'The receipt must declare available pear-v3 delivery, a canonical root install link, an exact product name, and supported targets.'))
294402 } else {
295 checks.push(reviewCheck('delivery-boundary', 'Delivery boundary', 'pass', 'No native executable target was declared and any manifest target matches the queued drive.'))
403 checks.push(reviewCheck('native-delivery', 'Pear v3 release identity', 'pass', `Canonical production identity: ${nativeDelivery.installLink}`))
404 checks.push(reviewCheck('native-product', 'Installed product', 'pass', `The catalogue will require the package product name “${nativeDelivery.productName}”.`))
405 checks.push(reviewCheck('native-targets', 'Release targets', 'pass', `Declared native targets: ${nativeDelivery.targets.join(', ')}.`))
296406 }
407 if (!boundedText(manifest.version, 40)) checks.push(reviewCheck('native-version', 'Released version', 'block', 'Pear v3 receipts must declare the package version being reviewed.'))
408 else checks.push(reviewCheck('native-version', 'Released version', 'pass', `Publisher declares package version ${boundedText(manifest.version, 40)}.`))
409 if (manifest.release?.publisherConfirmed !== true || manifest.release?.channel !== 'production') {
410 checks.push(reviewCheck('release-attestation', 'Release workflow', 'block', 'The publisher did not confirm a seeded production provision or multisig release line.'))
411 } else {
412 checks.push(reviewCheck('release-attestation', 'Release workflow', 'warning', 'The publisher confirmed the production release line. Independently verify pear info, seeding, provenance, and platform artifacts before publication.'))
413 }
414 checks.push(reviewCheck('install-boundary', 'Install-time verification', 'warning', 'PearBrowser does not execute or pin native release bytes during catalogue review. pear-install later enforces the requested release identity, one GUI artifact, product name, platform target, and normal OS destination.'))
415 projectedManifest = communityBeeEntry(manifest, now).value
416 } else if (manifest) {
417 checks.push(reviewCheck('delivery-boundary', 'Submission type', 'block', 'The receipt must declare submissionKind as hyper or pear-v3.'))
297418 }
298419
299420 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.'))
421 const names = duplicates.map((entry) => entry.name || entry.id || entry.driveKey || entry.nativeInstallLink).filter(Boolean)
422 checks.push(reviewCheck('duplicate', 'Catalogue duplication', 'warning', `This target already appears in ${duplicates.length} loaded catalogue entr${duplicates.length === 1 ? 'y' : 'ies'}${names.length ? ': ' + names.join(', ') : ''}. Confirm whether this is an update.`))
317423 } 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.'))
424 checks.push(reviewCheck('duplicate', 'Catalogue duplication', 'pass', 'No matching target is present in the catalogues currently loaded by this browser.'))
319425 }
320426
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.'))
427 checks.push(reviewCheck('human-preview', kind === 'pear-v3' ? 'Human release review' : 'Human preview', 'warning', kind === 'pear-v3'
428 ? 'Automated receipt checks cannot establish that a native app is trustworthy. Verify publisher provenance and release metadata independently before approving.'
429 : 'Automated checks cannot establish that a site is trustworthy. Open the target preview, inspect its behavior, and acknowledge the warnings.'))
322430
323 const discoveredAt = typeof pending.discoveredAt === 'number'
324 ? pending.discoveredAt
325 : Date.parse(String(pending.discoveredAt || ''))
431 const discoveredAt = typeof pending.discoveredAt === 'number' ? pending.discoveredAt : Date.parse(String(pending.discoveredAt || ''))
326432 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.'))
433 checks.push(reviewCheck('queue-age', 'Queue freshness', 'warning', 'This request has been pending for more than 30 days. Confirm the release is still current.'))
328434 }
329435
330436 const summary = checks.reduce((counts, check) => {
……@@ -333,30 +439,23 @@ function buildReviewReport (input = {}, now = Date.now()) {
333439 }, { pass: 0, warning: 0, block: 0 })
334440
335441 return {
336 appKey,
442 appKey: receiptKey,
443 receiptKey,
337444 publisherPubkey,
338 previewUrl: HEX64.test(appKey) ? `hyper://${appKey}/` : '',
445 submissionKind: kind || 'unknown',
446 previewUrl,
339447 status: summary.block ? 'blocked' : 'review-needed',
340448 approvalAllowed: summary.block === 0,
341449 requiresAcknowledgement: summary.warning > 0,
342450 checkedAt: now,
343451 summary,
344452 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,
453 manifest: projectedManifest,
358454 evidence: {
359 driveVersion,
455 driveVersion: kind === 'hyper' ? targetDriveVersion : receiptDriveVersion,
456 receiptDriveVersion,
457 targetDriveVersion,
458 targetDriveKey: boundedText(input.targetDriveKey, 64).toLowerCase(),
360459 indexBytes,
361460 externalOrigins,
362461 behaviorSignals,
……@@ -367,8 +466,13 @@ function buildReviewReport (input = {}, now = Date.now()) {
367466
368467module.exports = {
369468 HEX64,
469 PEAR_KEY,
370470 MAX_INDEX_BYTES,
471 MAX_ICON_DATA_CHARS,
472 NATIVE_TARGETS: [...NATIVE_TARGETS],
371473 slugify,
474 normalizePearInstallLink,
475 normalizeIconData,
372476 deriveKeyAndLink,
373477 buildSubmissionManifest,
374478 manageRequest,
backend/constants.js +3 -3
……@@ -183,11 +183,11 @@ const CMD_NOSTR_QUERY = 192 // { filter } → { events } (NIP-01 filter o
183183// relay pin requests. Approval authorizes replication only; publishing into the
184184// shared community Hyperbee is a separate operator step. See
185185// 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' }
186const CMD_SUBMIT_APP = 210 // { submissionKind:'pear-v3'|'hyper', name, link, version?, productName?, targets?, iconData? } → queue one receipt → status
187187const CMD_MOD_PENDING = 211 // → { pending: [...] } pulled from the relay review queue
188const CMD_MOD_APPROVE = 212 // { appKey, acknowledged, reviewedAt, reviewedDriveVersion, note } → guarded relay approval + local audit
188const CMD_MOD_APPROVE = 212 // { appKey, acknowledged, reviewedAt, reviewedReceiptDriveVersion, reviewedTargetDriveVersion, note } → guarded receipt approval + local audit
189189const 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
190const CMD_MOD_REVIEW = 214 // { appKey } → fetch receipt + optional Hyper target and return due-diligence evidence
191191
192192// Browser chrome-owned local AI. Unlike /api/ai/* these commands are not
193193// callable by Hyperdrive pages: only PearBrowser's renderer can use them.
backend/index.js +64 -43
……@@ -935,27 +935,23 @@ async function reviewPendingSubmission (appKey, pending = {}, opts = {}) {
935935 const cached = moderationReviewCache.get(normalizedKey)
936936 if (!opts.force && cached && Date.now() - cached.checkedAt < MODERATION_REVIEW_CACHE_MS) return cached
937937
938 let driveVersion = 0
938 let receiptDriveVersion = 0
939 let targetDriveVersion = 0
940 let targetDriveKey = ''
939941 let indexBuf = null
940942 let indexBytes = 0
941943 let manifestBuf = null
942944 let manifest = null
943945 let manifestError = ''
944 let fetchError = ''
946 let receiptFetchError = ''
947 let targetFetchError = ''
945948
946949 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 const receiptDrive = await withReviewTimeout(ensureBrowseDrive(normalizedKey), 12000, 'Receipt drive open')
951 try { await withReviewTimeout(receiptDrive.update({ wait: true }), 10000, 'Receipt drive update') } catch (_) {}
952 receiptDriveVersion = Number(receiptDrive.version) || 0
950953 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')
954 const manifestFile = await readBoundedReviewFile(receiptDrive, '/manifest.json', MAX_REVIEW_MANIFEST_BYTES, 8000, 'Receipt manifest')
959955 manifestBuf = manifestFile.buffer
960956 if (manifestFile.tooLarge) {
961957 manifestBuf = null
……@@ -964,9 +960,7 @@ async function reviewPendingSubmission (appKey, pending = {}, opts = {}) {
964960 } catch (err) {
965961 if (!/timed out/i.test(err && err.message)) manifestError = err && err.message ? err.message : String(err)
966962 }
967 } catch (err) {
968 fetchError = err && err.message ? err.message : String(err)
969 }
963 } catch (err) { receiptFetchError = err && err.message ? err.message : String(err) }
970964
971965 if (manifestBuf) {
972966 if (manifestBuf.length > MAX_REVIEW_MANIFEST_BYTES) {
……@@ -978,11 +972,34 @@ async function reviewPendingSubmission (appKey, pending = {}, opts = {}) {
978972 }
979973 }
980974
975 if (manifest && manifest.submissionKind === 'hyper') {
976 const target = communitySubmit.deriveKeyAndLink(manifest.link || manifest.driveKey, normalizeDriveKey)
977 if (target.error) {
978 targetFetchError = target.error
979 } else {
980 targetDriveKey = target.driveKey
981 try {
982 const targetDrive = await withReviewTimeout(ensureBrowseDrive(targetDriveKey), 12000, 'Target drive open')
983 try { await withReviewTimeout(targetDrive.update({ wait: true }), 10000, 'Target drive update') } catch (_) {}
984 targetDriveVersion = Number(targetDrive.version) || 0
985 const indexFile = await readBoundedReviewFile(targetDrive, '/index.html', MAX_REVIEW_INDEX_BYTES, 12000, 'Target index.html')
986 indexBuf = indexFile.buffer
987 indexBytes = indexFile.byteLength
988 if (indexFile.tooLarge) indexBytes = Math.max(indexBytes, MAX_REVIEW_INDEX_BYTES + 1)
989 } catch (err) { targetFetchError = err && err.message ? err.message : String(err) }
990 }
991 }
992
981993 const duplicates = catalogManager
982994 ? catalogManager.getAggregatedApps().filter((app) => {
995 if (manifest && manifest.submissionKind === 'pear-v3') {
996 const submitted = communitySubmit.normalizePearInstallLink(manifest.nativeDelivery && manifest.nativeDelivery.installLink)
997 const existing = communitySubmit.normalizePearInstallLink(app && app.nativeDelivery && app.nativeDelivery.installLink)
998 return !submitted.error && !existing.error && submitted.installLink === existing.installLink
999 }
9831000 const driveKey = normalizeDriveKey(String((app && app.driveKey) || ''))
9841001 const linkKey = driveKeyFromUrl(app && app.link)
985 return driveKey === normalizedKey || linkKey === normalizedKey
1002 return !!targetDriveKey && (driveKey === targetDriveKey || linkKey === targetDriveKey)
9861003 })
9871004 : []
9881005
……@@ -993,9 +1010,12 @@ async function reviewPendingSubmission (appKey, pending = {}, opts = {}) {
9931010 manifestError,
9941011 indexText: indexBuf ? Buffer.from(indexBuf).toString('utf8') : '',
9951012 indexBytes,
996 driveVersion,
1013 receiptDriveVersion,
1014 targetDriveVersion,
1015 targetDriveKey,
9971016 duplicates,
998 fetchError,
1017 receiptFetchError,
1018 targetFetchError,
9991019 normalizeKey: normalizeDriveKey
10001020 }, Date.now())
10011021 moderationReviewCache.set(normalizedKey, report)
……@@ -1090,43 +1110,42 @@ rpc.handle(C.CMD_SUBMIT_APP, async (data = {}) => {
10901110 submittedBy, now: Date.now(), normalizeKey: normalizeDriveKey
10911111 })
10921112 if (built.error) throw new Error(built.error)
1093 const { manifest, id, driveKey } = built
1113 const { manifest, id, kind, driveKey, installLink, receiptId } = built
10941114
1095 // 1) Publish the submission manifest drive (durable metadata: name/desc/icon)
1096 // so the moderator + mobile firehose can read it later. seed:true pins it.
1115 // Publish exactly one bounded receipt drive and put that receipt in the relay
1116 // review queue. The receipt references either separately seeded Hyper content
1117 // or a Pear v3 production identity. It never asks HiveRelay to pin native
1118 // release bytes, and it avoids the old double-queue where moderators saw the
1119 // target drive but could not retrieve the separately published form metadata.
10971120 let manifestKey = null
1098 let receiptWarning = ''
1121 let acceptances = 0
10991122 try {
11001123 const mdrive = await hiveRelay.publish(
11011124 [{ path: '/manifest.json', content: JSON.stringify(manifest, null, 2) }],
1102 { appId: id, seed: true, replicas: 3, ttlDays: 365 }
1125 { appId: receiptId, seed: true, replicas: 3, ttlDays: 365, timeout: 15000 }
11031126 )
11041127 if (mdrive && mdrive.key) {
1105 manifestKey = Buffer.isBuffer(mdrive.key) ? mdrive.key.toString('hex') : String(mdrive.key)
1128 manifestKey = normalizeDriveKey(Buffer.isBuffer(mdrive.key) ? mdrive.key.toString('hex') : String(mdrive.key))
11061129 }
1130 acceptances = Number(mdrive && mdrive.replicas && mdrive.replicas.accepted) || 0
11071131 } catch (err) {
11081132 console.error('[submit] publish manifest failed:', err && err.message)
1109 receiptWarning = 'The submission receipt could not be published yet.'
1110 }
1111 if (!manifestKey && !receiptWarning) receiptWarning = 'The submission receipt publisher returned no drive key.'
1112
1113 // 2) Seed the app's CONTENT drive. In `review` mode this queues a pin request
1114 // in the relay's pending queue for the moderator to approve.
1115 let acceptances = 0
1116 try {
1117 const res = await hiveRelay.seed(driveKey, { replicas: 3, ttlDays: 365, timeout: 15000 })
1118 acceptances = Array.isArray(res) ? res.length : 0
1119 } catch (err) {
1120 console.error('[submit] seed app drive failed:', err && err.message)
1133 throw new Error('The catalogue receipt could not be published: ' + boundedReviewText(err && err.message, 300))
11211134 }
1135 if (!manifestKey) throw new Error('The catalogue receipt publisher returned no valid drive key.')
11221136
1123 console.log(`[submit] queued "${manifest.name}" (${id}) drive=${driveKey.slice(0, 8)} acceptances=${acceptances}`)
1137 const receiptWarning = acceptances > 0
1138 ? ''
1139 : 'No relay acknowledged the review receipt yet; keep this browser online and retry if it does not appear in the operator queue.'
1140 console.log(`[submit] queued "${manifest.name}" (${id}) kind=${kind} receipt=${manifestKey.slice(0, 8)} acceptances=${acceptances}`)
11241141 return {
11251142 ok: true,
11261143 id,
11271144 driveKey,
1145 installLink,
1146 kind,
11281147 manifestKey,
1129 receiptPublished: !!manifestKey,
1148 receiptPublished: true,
11301149 receiptWarning,
11311150 acceptances,
11321151 status: acceptances > 0 ? 'pending-review' : 'awaiting-relay',
……@@ -1187,20 +1206,22 @@ rpc.handle(C.CMD_MOD_APPROVE, async (data = {}) => {
11871206 const pending = moderationPendingByKey.get(appKey)
11881207 if (!pending) throw new Error('This app is not in the loaded relay queue. Refresh pending submissions before approving it.')
11891208 const cachedReview = moderationReviewCache.get(appKey)
1190 const reviewedDriveVersion = Number(data.reviewedDriveVersion)
1209 const reviewedReceiptDriveVersion = Number(data.reviewedReceiptDriveVersion)
1210 const reviewedTargetDriveVersion = Number(data.reviewedTargetDriveVersion || 0)
11911211 if (!communitySubmit.reviewEvidenceMatches(data, cachedReview)) {
11921212 throw new Error('The due-diligence evidence does not match this approval. Re-run the review before approving.')
11931213 }
11941214 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.`)
1215 if (review.evidence.receiptDriveVersion !== reviewedReceiptDriveVersion ||
1216 review.evidence.targetDriveVersion !== reviewedTargetDriveVersion) {
1217 throw new Error('The receipt or target content changed after review. Inspect the refreshed evidence before approving.')
11971218 }
11981219 if (!review.approvalAllowed) {
11991220 const blockers = review.checks.filter((check) => check.status === 'block').map((check) => check.label).join(', ')
12001221 throw new Error('Due diligence blocked approval: ' + (blockers || 'review evidence failed') + '.')
12011222 }
12021223 if (review.requiresAcknowledgement && data.acknowledged !== true) {
1203 throw new Error('A reviewer must preview the app and acknowledge the due-diligence warnings before approval.')
1224 throw new Error('A reviewer must complete the human release review and acknowledge the due-diligence warnings before approval.')
12041225 }
12051226 const note = boundedReviewText(data.note, 1000)
12061227 if (!note) throw new Error('Record a reviewer note describing the due diligence performed before approval.')
catalog-source/pearbrowser-network.catalog.json +4 -3
……@@ -1,8 +1,8 @@
11{
22 "name": "PearBrowser Network",
33 "description": "Curated entry point for the Pear ecosystem — the browser itself plus its companion sites.",
4 "version": 10,
5 "publishedAt": "2026-07-16T00:00:00Z",
4 "version": 11,
5 "publishedAt": "2026-07-29T00:00:00Z",
66 "publisher": "bigdestiny2",
77 "apps": [
88 {
……@@ -10,13 +10,14 @@
1010 "name": "PearBrowser Desktop",
1111 "description": "P2P browser, decentralized app store, and site publisher. macOS / Windows / Linux. Talks to peers directly over Hyperswarm. Pinned 24/7 on HiveRelay.",
1212 "author": "bigdestiny2",
13 "version": "0.7.1",
13 "version": "0.8.0",
1414 "categories": [
1515 "browser",
1616 "tools",
1717 "p2p"
1818 ],
1919 "publishedAt": "2026-04-28T00:00:00Z",
20 "type": "standalone",
2021 "legacyMigrationId": "tco5k7h38uoxatedp1wongdbhjxow1x7jiwm3t1i9cujbebhsbty",
2122 "nativeDelivery": { "status": "migration-required" },
2223 "driveKey": "03f0060a35451cfb6b68ad1dda1b8474ebb43fd9100071ccf7d67679a83ebb4f",
docs/COMMUNITY_REVIEW_PROCESS.md +104 -71
……@@ -1,82 +1,115 @@
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.
1# Community catalogue submission and review
2
3PearBrowser separates four concerns that must not be collapsed into one button:
4
51. an author releases and distributes an app;
62. the author submits a bounded catalogue receipt;
73. an operator reviews and approves that receipt;
84. a secret-holding publisher writes approved metadata to the Community catalogue.
9
10A relay approval only allows the receipt drive to be replicated. It does not
11endorse an app, pin a native release, or publish the shared catalogue entry.
12
13## Pear v3 native apps
14
15Pear v3 applications are released through Pear's deployment pipeline, not by
16uploading executable bytes to PearBrowser or HiveRelay:
17
181. mint and seed the release lines with `pear touch` / `pear seed`;
192. set the package `upgrade` identity;
203. build platform artifacts and assemble the deployment with `pear build`;
214. dry-run and apply `pear stage`;
225. move the staged version to a provision line and, for production, gate it
23 through multisig;
246. keep the stable root production link seeded.
25
26The catalogue form accepts only a canonical root
27`pear://<52-character-key>` identity. Versioned stage links, paths, query
28strings, and fragments are rejected. Authors must state the package version,
29the exact installed product name, and every OS/architecture target included in
30the release. The resulting catalogue entry uses:
31
32```json
33{
34 "type": "standalone",
35 "nativeDelivery": {
36 "status": "available",
37 "kind": "pear-v3",
38 "installLink": "pear://<52-character-production-key>",
39 "productName": "Example App",
40 "targets": ["darwin-arm64", "linux-x64", "win32-x64"]
41 }
42}
43```
44
45PearBrowser never runs a submitted link during review. When a user later
46chooses **Install app**, the existing `pear-install` boundary verifies the
47requested release identity, exact product name, platform target, one GUI
48artifact, and normal OS destination before recording the installation.
49
50## Browsable Hyper content
51
52A Hyper submission references a separately published and seeded drive. Its
53root `/index.html` must be available to a cold reader. The catalogue receipt
54does not automatically pin the target drive and does not turn Hyper content
55into a native application.
56
57## Receipt lifecycle
58
591. The author selects **Pear v3 app** or **Hyper site**, enters bounded
60 catalogue metadata, and optionally uploads a small image icon.
612. PearBrowser validates the target and publishes one receipt drive containing
62 `/manifest.json`.
633. A signed HiveRelay seed request places that receipt drive in a relay's
64 authenticated `review` queue.
654. The operator loads the queue and fetches the receipt. Hyper reviews also
66 fetch the separately declared target drive and inspect `/index.html`.
675. The operator records a note and either approves receipt replication or
68 rejects the request with a reason.
696. Approved metadata is published through the separate secret-holding
70 Community catalogue release process and verified from a fresh reader.
71
72Using the receipt as the single queue item avoids the former double-queue bug:
73the old flow seeded a metadata drive and the app drive independently, while the
74moderator only knew the app key and therefore could not reliably retrieve the
75form metadata.
2776
2877## Automated due diligence
2978
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.
79All reviews verify a canonical receipt key, relay-visible publisher key,
80bounded receipt manifest, supported receipt version, duplicate target state,
81and queue freshness.
82
83Hyper reviews additionally verify the declared target key, target replication
84version, bounded root `/index.html`, and high-signal page behavior. Pear v3
85reviews validate the canonical production identity, package version, exact
86product name, supported target list, and publisher release attestation. Native
87package availability and provenance remain explicit human-review items because
88catalogue review must not install or execute third-party code.
89
90An icon is presentation metadata only. Uploads are capped to the shared
91catalogue limit and restricted to PNG, JPEG, WebP, GIF, or passive SVG bytes.
92They never affect trust or launch decisions.
4993
5094## Decision records
5195
96Approval fails closed when evidence changes, any blocker remains, the review
97queue is stale or not in `review` mode, the human acknowledgement is missing,
98or no reviewer note is recorded. Evidence binds approval to both the receipt
99drive version and, for Hyper submissions, the target drive version.
100
52101Approvals 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.
102the receipt key, publisher key, action, note or rejection reason, decision
103time, and check counts. Operator credentials are never copied into audit data.
67104
68105## Operator checklist
69106
70107- 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.
108- Refresh and run due diligence immediately before the decision.
109- For a Hyper site, open the target preview and investigate warnings.
110- For a Pear v3 app, independently verify publisher provenance, `pear info`,
111 production seeding, version, product name, signing, and declared artifacts.
112- Record what was checked and acknowledge the remaining limits.
113- Approve only the receipt or reject it with an actionable reason.
114- Publish approved metadata through the Community catalogue publisher and
115 verify installation or browsing from a clean reader.
docs/INSTALL_NATIVE_PACKAGES.md +15 -15
……@@ -1,6 +1,6 @@
11# Install Native Packages
22
3Current release: `v0.7.1`.
3Current release: `v0.8.0`.
44
55These are package-proof desktop builds. Linux uses checksums only. macOS is
66ad-hoc signed but not notarized, and Windows packages are unsigned until the
……@@ -12,14 +12,14 @@ PearBrowser is free of malware until a signed and notarized DMG is published.
1212## Choose A Package
1313
1414Download directly from the
15[`v0.7.1` GitHub release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.7.1).
15[`v0.8.0` GitHub release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.8.0).
1616
1717| Machine | Recommended package | Checksum sidecar |
1818| --- | --- | --- |
19| macOS Apple Silicon | [PearBrowser-0.7.1-macos-arm64.app.zip](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-macos-arm64.app.zip) | [PearBrowser-0.7.1-macos-arm64.app.zip.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-macos-arm64.app.zip.sha256) |
20| macOS Intel | [PearBrowser-0.7.1-macos-x64.app.zip](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-macos-x64.app.zip) | [PearBrowser-0.7.1-macos-x64.app.zip.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-macos-x64.app.zip.sha256) |
21| Windows x64 | [PearBrowser-0.7.1-windows-x64.msix](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-windows-x64.msix) | [PearBrowser-0.7.1-windows-x64.msix.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-windows-x64.msix.sha256) |
22| Linux x64 | [PearBrowser-0.7.1-linux-x64.AppImage](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-linux-x64.AppImage) | [PearBrowser-0.7.1-linux-x64.AppImage.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-linux-x64.AppImage.sha256) |
19| macOS Apple Silicon | [PearBrowser-0.8.0-macos-arm64.app.zip](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.8.0/PearBrowser-0.8.0-macos-arm64.app.zip) | [PearBrowser-0.8.0-macos-arm64.app.zip.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.8.0/PearBrowser-0.8.0-macos-arm64.app.zip.sha256) |
20| macOS Intel | [PearBrowser-0.8.0-macos-x64.app.zip](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.8.0/PearBrowser-0.8.0-macos-x64.app.zip) | [PearBrowser-0.8.0-macos-x64.app.zip.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.8.0/PearBrowser-0.8.0-macos-x64.app.zip.sha256) |
21| Windows x64 | [PearBrowser-0.8.0-windows-x64.msix](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.8.0/PearBrowser-0.8.0-windows-x64.msix) | [PearBrowser-0.8.0-windows-x64.msix.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.8.0/PearBrowser-0.8.0-windows-x64.msix.sha256) |
22| Linux x64 | [PearBrowser-0.8.0-linux-x64.AppImage](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.8.0/PearBrowser-0.8.0-linux-x64.AppImage) | [PearBrowser-0.8.0-linux-x64.AppImage.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.8.0/PearBrowser-0.8.0-linux-x64.AppImage.sha256) |
2323
2424The Linux release contains exactly one product AppImage. Packaging now rejects
2525ambiguous AppImages and excludes build tools such as AppImageTool.
……@@ -31,26 +31,26 @@ native release workflow.
3131From a source checkout, ask the resolver for the current machine:
3232
3333```sh
34npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop
34npm run resolve:native-release -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop
3535```
3636
3737Or specify a target:
3838
3939```sh
40npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --platform macos --arch x64
40npm run resolve:native-release -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop --platform macos --arch x64
4141```
4242
4343Release operators can verify every recommended package download and checksum
4444sidecar in one pass:
4545
4646```sh
47npm run verify:native-downloads -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --all
47npm run verify:native-downloads -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop --all
4848```
4949
5050Release operators can regenerate this guide from the same resolver rules:
5151
5252```sh
53npm run -s generate:native-install-guide -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop
53npm run -s generate:native-install-guide -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop
5454```
5555
5656## Verify The Download
……@@ -58,7 +58,7 @@ npm run -s generate:native-install-guide -- --tag v0.7.1 --repo bigdestiny2/pear
5858macOS and Linux:
5959
6060```sh
61shasum -a 256 -c PearBrowser-0.7.1-macos-arm64.app.zip.sha256
61shasum -a 256 -c PearBrowser-0.8.0-macos-arm64.app.zip.sha256
6262```
6363
6464Use the matching filename for your package. A passing check prints `OK`.
……@@ -66,7 +66,7 @@ Use the matching filename for your package. A passing check prints `OK`.
6666Windows PowerShell:
6767
6868```powershell
69$package = "PearBrowser-0.7.1-windows-x64.msix"
69$package = "PearBrowser-0.8.0-windows-x64.msix"
7070$expected = (Get-Content "$($package).sha256").Split(" ")[0].ToLowerInvariant()
7171$actual = (Get-FileHash $package -Algorithm SHA256).Hash.ToLowerInvariant()
7272if ($actual -ne $expected) { throw "SHA-256 mismatch for $package" }
……@@ -88,7 +88,7 @@ macOS:
8888
8989Windows:
9090
911. Install `PearBrowser-0.7.1-windows-x64.msix`.
911. Install `PearBrowser-0.8.0-windows-x64.msix`.
92922. For package-proof builds, Windows SmartScreen may warn because the installer
9393 is not yet Authenticode-signed. Continue only if you intentionally trust this
9494 package and its checksum.
……@@ -96,8 +96,8 @@ Windows:
9696Linux:
9797
9898```sh
99chmod +x PearBrowser-0.7.1-linux-x64.AppImage
100./PearBrowser-0.7.1-linux-x64.AppImage
99chmod +x PearBrowser-0.8.0-linux-x64.AppImage
100./PearBrowser-0.8.0-linux-x64.AppImage
101101```
102102
103103## Legacy Pear records
docs/RELEASE_NOTES_v0.8.0.md +39 -0
……@@ -0,0 +1,39 @@
1# PearBrowser Desktop v0.8.0
2
3PearBrowser v0.8.0 makes Pear v3 delivery a first-class, explicit catalogue
4contract. Native apps are installed as verified OS packages through a
5host-confirmed action; Hyper sites remain browsable peer-to-peer content.
6
7## Highlights
8
9- Submit a released Pear v3 app with its canonical root production link,
10 version, exact installed product name, and platform targets.
11- Upload a bounded PNG, JPEG, WebP, GIF, or safe SVG catalogue icon.
12- Review one signed submission receipt instead of conflating native package
13 distribution with Hyper content seeding.
14- Bind approval to the exact reviewed receipt and, for Hyper sites, the exact
15 reviewed content version.
16- Install compatible native releases through the Pear v3 launcher with strict
17 identity, target, destination, upgrade, and one-GUI-artifact checks.
18- See Pear v3 listings on mobile as desktop-only metadata; mobile never opens
19 them as web content.
20
21The release also includes the embedded Pear v3 host migration, private-search
22home, reconnect-safe renderer/backend RPC, Content Shield, Pear Plugins, local
23Ask Browser/QVAC, federated search, naming, Nostr, and P2P publishing work that
24landed after v0.7.1.
25
26## Trust and distribution note
27
28The GitHub native workflow currently produces package-proof assets with
29SHA-256 sidecars. macOS Developer ID notarization and Windows public-trust
30signing remain unavailable until production credentials are configured. The
31retained PearBrowser upgrade identity remains a migration record until the
32human-controlled Pear v3 provision/multisig release is independently verified.
33
34## Verification
35
36- Desktop: 700/700 tests.
37- Mobile: 566/566 tests.
38- iOS: unsigned generic-device build succeeded.
39- Android: debug Kotlin compilation succeeded.
package-lock.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "pearbrowser-desktop",
3 "version": "0.7.1",
3 "version": "0.8.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "pearbrowser-desktop",
9 "version": "0.7.1",
9 "version": "0.8.0",
1010 "hasInstallScript": true,
1111 "license": "Apache-2.0 AND MIT",
1212 "dependencies": {
package.json +1 -1
……@@ -1,7 +1,7 @@
11{
22 "name": "pearbrowser-desktop",
33 "productName": "PearBrowser",
4 "version": "0.7.1",
4 "version": "0.8.0",
55 "description": "Local-first P2P browser, catalogue, and publishing platform with an embedded Pear OTA runtime.",
66 "license": "Apache-2.0 AND MIT",
77 "type": "module",
scripts/gen-catalogue-seed.mjs +10 -4
……@@ -14,8 +14,8 @@
1414// The manifest is rich (id, pearLink, homepage, source, license, platforms, …);
1515// seed rows are validated against the strict APPS_SCHEMA
1616// (additionalProperties:false), so we project onto exactly its allowed fields and
17// infer `type` from `link`. Native migration records retain only a local
18// migration ID, never an executable remote URI.
17// infer `type` from `link`. Native delivery is projected into the schema's
18// dedicated columns and never copied into the browsable `link` field.
1919
2020import { readFileSync, writeFileSync } from 'node:fs'
2121import { fileURLToPath } from 'node:url'
……@@ -54,7 +54,13 @@ function toSeedRow (a) {
5454 if (a.driveKey && /^[0-9a-f]{64}$/i.test(a.driveKey)) row.driveKey = a.driveKey
5555 if (a.link) row.link = a.link
5656 if (typeof a.legacyMigrationId === 'string') row.legacyMigrationId = a.legacyMigrationId
57 if (a.nativeDelivery && typeof a.nativeDelivery.status === 'string') row.nativeDeliveryStatus = a.nativeDelivery.status
57 if (a.nativeDelivery && typeof a.nativeDelivery.status === 'string') {
58 row.nativeDeliveryStatus = a.nativeDelivery.status
59 if (a.nativeDelivery.kind) row.nativeDeliveryKind = a.nativeDelivery.kind
60 if (a.nativeDelivery.installLink) row.nativeInstallLink = a.nativeDelivery.installLink
61 if (a.nativeDelivery.productName) row.nativeProductName = a.nativeDelivery.productName
62 if (Array.isArray(a.nativeDelivery.targets) && a.nativeDelivery.targets.length) row.nativeTargets = a.nativeDelivery.targets
63 }
5864 // Inline icon (data: URI) for apps without a fetchable drive icon — rendered
5965 // directly by AppIcon, so every catalogue app shows a real icon, not a glyph.
6066 if (a.iconData) row.iconData = a.iconData
……@@ -72,7 +78,7 @@ function toSeedRow (a) {
7278const rows = cat.apps.map(toSeedRow)
7379for (const [i, r] of rows.entries()) {
7480 if (!r.name || !r.type) throw new Error(`row ${i} missing name/type`)
75 if (!r.driveKey && !r.link && !r.legacyMigrationId) throw new Error(`row ${i} (${r.name}) has neither driveKey, link, nor legacy migration ID`)
81 if (!r.driveKey && !r.link && !r.legacyMigrationId && !r.nativeInstallLink) throw new Error(`row ${i} (${r.name}) has no browsable, migration, or native delivery identity`)
7682}
7783
7884const header = `/**
scripts/lib/catalog-bee.js +2 -0
……@@ -59,6 +59,8 @@ export function normalizeManifest (manifest, now = Date.now()) {
5959 if (typeof clean.sourceUrl === 'string' && clean.sourceUrl) entry.sourceUrl = clean.sourceUrl
6060 if (typeof clean.license === 'string' && clean.license) entry.license = clean.license
6161 if (clean.type === 'standalone' || clean.type === 'hypersite') entry.type = clean.type
62 if (typeof clean.legacyMigrationId === 'string' && clean.legacyMigrationId) entry.legacyMigrationId = clean.legacyMigrationId
63 if (clean.nativeDelivery) entry.nativeDelivery = clean.nativeDelivery
6264 // Inline icon (data: URI) — carried so apps WITHOUT a fetchable drive icon
6365 // (pear://-only apps, or drives lacking /icon.*) still render a real icon.
6466 if (typeof clean.iconData === 'string' && clean.iconData) entry.iconData = clean.iconData
scripts/verify-live-catalog.js +20 -3
……@@ -126,8 +126,25 @@ async function main () {
126126 if (pearbrowser.version !== args.expectBrowserVersion) {
127127 fail(`PearBrowser version mismatch: expected ${args.expectBrowserVersion}, got ${pearbrowser.version || '(missing)'}`)
128128 }
129 if (pearbrowser.link !== args.expectBrowserLink) {
130 fail(`PearBrowser link mismatch: expected ${args.expectBrowserLink}, got ${pearbrowser.link || '(missing)'}`)
129 if (browserSource.nativeDelivery?.status === 'available') {
130 if (pearbrowser.nativeDelivery?.installLink !== args.expectBrowserLink) {
131 fail(`PearBrowser native install link mismatch: expected ${args.expectBrowserLink}, got ${pearbrowser.nativeDelivery?.installLink || '(missing)'}`)
132 }
133 if (pearbrowser.nativeDelivery?.status !== 'available' || pearbrowser.nativeDelivery?.kind !== 'pear-v3') {
134 fail('PearBrowser native delivery must be an available Pear v3 release')
135 }
136 if (pearbrowser.nativeDelivery?.productName !== pkg.productName) {
137 fail(`PearBrowser product name mismatch: expected ${pkg.productName}, got ${pearbrowser.nativeDelivery?.productName || '(missing)'}`)
138 }
139 if (!Array.isArray(pearbrowser.nativeDelivery?.targets) || pearbrowser.nativeDelivery.targets.length === 0) {
140 fail('PearBrowser native delivery targets are missing')
141 }
142 } else {
143 const expectedMigrationId = args.expectBrowserLink.replace(/^pear:\/\//, '')
144 if (pearbrowser.legacyMigrationId !== expectedMigrationId || pearbrowser.nativeDelivery?.status !== 'migration-required') {
145 fail('PearBrowser migration identity mismatch')
146 }
147 if (pearbrowser.nativeDelivery?.installLink) fail('PearBrowser migration record must not expose an install link')
131148 }
132149 if (pearbrowser.homepage !== args.expectBrowserHomepage) {
133150 fail(`PearBrowser homepage mismatch: expected ${args.expectBrowserHomepage}, got ${pearbrowser.homepage || '(missing)'}`)
……@@ -157,7 +174,7 @@ async function main () {
157174
158175 console.log(' → signed meta signature:', signedMeta.value.signature.slice(0, 16) + '…')
159176 console.log(' → catalogue:', data.name, '· apps:', data.apps.length)
160 console.log(' → PearBrowser:', pearbrowser.version, '·', pearbrowser.link, '·', pearbrowser.homepage)
177 console.log(' → PearBrowser:', pearbrowser.version, '·', pearbrowser.nativeDelivery?.installLink || `migration:${pearbrowser.legacyMigrationId}`, '·', pearbrowser.homepage)
161178 if (peercord) console.log(' → Peercord:', peercord.type, '·', peercord.sourceUrl, '·', peercord.license)
162179 console.log(' → expected apps:', args.expectApps.length ? args.expectApps.join(', ') : '(none)')
163180 console.log()
styles.css +69 -1
……@@ -2364,7 +2364,74 @@ 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. */
2367/* Community catalogue submission and moderation queue. */
2368.community-kind {
2369 display: flex;
2370 gap: 8px;
2371 margin-bottom: 12px;
2372}
2373.community-release-note {
2374 margin-bottom: 14px;
2375 padding: 10px 12px;
2376 border-left: 3px solid var(--pear-color-link);
2377 border-radius: 7px;
2378 background: var(--pear-color-surface-soft);
2379 color: var(--pear-color-muted);
2380 font-size: 12px;
2381 line-height: 1.55;
2382}
2383.community-release-note strong { color: var(--pear-color-text); }
2384.community-targets {
2385 display: grid;
2386 grid-template-columns: repeat(2, minmax(0, 1fr));
2387 gap: 7px 12px;
2388 margin: 6px 0 14px;
2389}
2390.community-targets label,
2391.community-release-confirm {
2392 display: flex;
2393 align-items: flex-start;
2394 gap: 8px;
2395 color: var(--pear-color-text);
2396 font-size: 12px;
2397 cursor: pointer;
2398}
2399.community-targets input,
2400.community-release-confirm input {
2401 margin-top: 2px;
2402 accent-color: var(--pear-color-link);
2403}
2404.community-icon-upload {
2405 display: flex;
2406 align-items: center;
2407 flex-wrap: wrap;
2408 gap: 9px;
2409 margin: 7px 0 14px;
2410}
2411.community-icon-upload img,
2412.community-icon-upload .app-icon {
2413 width: 44px;
2414 height: 44px;
2415 flex: 0 0 44px;
2416 border-radius: 10px;
2417 object-fit: cover;
2418}
2419.community-icon-upload label.btn { cursor: pointer; }
2420.community-icon-upload input[type="file"] {
2421 position: absolute;
2422 width: 1px;
2423 height: 1px;
2424 overflow: hidden;
2425 clip: rect(0 0 0 0);
2426 white-space: nowrap;
2427}
2428.community-release-confirm {
2429 margin: 4px 0 16px;
2430 padding: 11px 12px;
2431 border: 1px solid rgba(185,120,18,0.28);
2432 border-radius: 8px;
2433 background: rgba(185,120,18,0.08);
2434}
23682435.mod-process {
23692436 display: flex;
23702437 align-items: center;
……@@ -2497,6 +2564,7 @@ p { color: var(--pear-color-muted); margin-bottom: 12px; }
24972564.mod-audit-row code { color: var(--pear-color-text); }
24982565.mod-audit-row time { white-space: nowrap; }
24992566@media (max-width: 760px) {
2567 .community-targets { grid-template-columns: 1fr; }
25002568 .mod-audit-row { grid-template-columns: auto 1fr; }
25012569 .mod-audit-row span:nth-child(3), .mod-audit-row time { grid-column: 1 / -1; }
25022570}
test/catalog-bee.test.js +25 -4
……@@ -15,7 +15,20 @@ const SAMPLE = {
1515 version: 1,
1616 apps: [
1717 { id: 'keet', name: 'Keet', type: 'standalone', description: 'P2P chat', link: 'hyper://' + 'b'.repeat(64) + '/', categories: ['chat'], author: 'Holepunch', homepage: 'https://keet.io', source: 'https://github.com/holepunchto/keet', license: 'Apache-2.0' },
18 { id: 'pearpass', name: 'PearPass', driveKey: 'a'.repeat(64), version: '2.0' }
18 { id: 'pearpass', name: 'PearPass', driveKey: 'a'.repeat(64), version: '2.0' },
19 {
20 id: 'native-tool',
21 name: 'Native Tool',
22 type: 'standalone',
23 version: '3.2.1',
24 nativeDelivery: {
25 status: 'available',
26 kind: 'pear-v3',
27 installLink: 'pear://' + 'a'.repeat(52),
28 productName: 'Native Tool',
29 targets: ['darwin-arm64', 'linux-x64']
30 }
31 }
1932 ]
2033}
2134
……@@ -23,7 +36,7 @@ test('normalizeManifest cleans input and applies defaults', () => {
2336 const n = normalizeManifest(SAMPLE, 1700000000000)
2437 assert.equal(n.name, 'Pear Picks') // trimmed
2538 assert.equal(n.version, 1)
26 assert.equal(n.apps.length, 2)
39 assert.equal(n.apps.length, 3)
2740 assert.equal(n.apps[0].publishedAt, 1700000000000) // injected default now
2841 assert.deepEqual(n.apps[0].categories, ['chat'])
2942 assert.equal(n.apps[0].sourceUrl, 'https://github.com/holepunchto/keet')
……@@ -74,8 +87,8 @@ test('round-trips through a real Hyperbee using the loader query', async () => {
7487 assert.equal(data.name, 'Pear Picks')
7588 assert.equal(data.version, 1)
7689 assert.equal(data.sourceKey, keyHex)
77 assert.equal(data.count.total, 2)
78 assert.equal(data.apps.length, 2)
90 assert.equal(data.count.total, 3)
91 assert.equal(data.apps.length, 3)
7992
8093 const byId = Object.fromEntries(data.apps.map((a) => [a.id, a]))
8194 assert.equal(byId.keet.name, 'Keet')
……@@ -86,6 +99,14 @@ test('round-trips through a real Hyperbee using the loader query', async () => {
8699 assert.equal(byId.keet.homepage, 'https://keet.io')
87100 assert.equal(byId.keet.license, 'Apache-2.0')
88101 assert.equal(byId.pearpass.driveKey, 'a'.repeat(64))
102 assert.deepEqual(byId['native-tool'].nativeDelivery, {
103 status: 'available',
104 kind: 'pear-v3',
105 installLink: 'pear://' + 'a'.repeat(52),
106 productName: 'Native Tool',
107 targets: ['darwin-arm64', 'linux-x64']
108 })
109 assert.equal(byId['native-tool'].link, undefined)
89110
90111 await store.close()
91112 } finally {
test/community-submit-contract.test.js +63 -0
……@@ -0,0 +1,63 @@
1import test from 'node:test'
2import assert from 'node:assert/strict'
3import { readFileSync } from 'node:fs'
4
5const backend = readFileSync(new URL('../backend/index.js', import.meta.url), 'utf8')
6const ui = readFileSync(new URL('../ui/shell.js', import.meta.url), 'utf8')
7const constants = readFileSync(new URL('../backend/constants.js', import.meta.url), 'utf8')
8const seedGenerator = readFileSync(new URL('../scripts/gen-catalogue-seed.mjs', import.meta.url), 'utf8')
9
10function between (source, start, end) {
11 const from = source.indexOf(start)
12 const to = source.indexOf(end, from + start.length)
13 assert.ok(from >= 0, `missing start marker: ${start}`)
14 assert.ok(to > from, `missing end marker: ${end}`)
15 return source.slice(from, to)
16}
17
18test('submission RPC queues exactly one catalogue receipt instead of the target release', () => {
19 const handler = between(backend, 'rpc.handle(C.CMD_SUBMIT_APP', 'rpc.handle(C.CMD_MOD_PENDING')
20 assert.match(handler, /appId:\s*receiptId/)
21 assert.match(handler, /seed:\s*true/)
22 assert.match(handler, /mdrive\.replicas\.accepted/)
23 assert.doesNotMatch(handler, /hiveRelay\.seed\s*\(/)
24 assert.match(handler, /installLink/)
25})
26
27test('moderation fetches the receipt first and Hyper target content separately', () => {
28 const review = between(backend, 'async function reviewPendingSubmission', '// Minimal JSON HTTP')
29 assert.match(review, /Receipt drive open/)
30 assert.match(review, /manifest\.submissionKind === 'hyper'/)
31 assert.match(review, /Target drive open/)
32 assert.match(review, /receiptDriveVersion/)
33 assert.match(review, /targetDriveVersion/)
34})
35
36test('renderer exposes Pear v3 metadata, target selection, and bounded icon upload', () => {
37 const form = between(ui, 'function CommunitySubmit', '// In-app moderator panel')
38 assert.match(form, /submissionKind/)
39 assert.match(form, /releaseConfirmed/)
40 assert.match(form, /productName/)
41 assert.match(form, /targets/)
42 assert.match(form, /type="file"/)
43 assert.match(form, /image\/png,image\/jpeg,image\/webp,image\/gif,image\/svg\+xml/)
44 assert.match(form, /iconData/)
45 assert.match(form, /pear build/)
46 assert.match(form, /pear provision/)
47})
48
49test('approval RPC contract binds receipt and optional target versions', () => {
50 assert.match(constants, /reviewedReceiptDriveVersion/)
51 assert.match(constants, /reviewedTargetDriveVersion/)
52 const moderator = between(ui, 'function ModeratorPanel', '// Browser-side defensive dedup')
53 assert.match(moderator, /reviewedReceiptDriveVersion/)
54 assert.match(moderator, /reviewedTargetDriveVersion/)
55})
56
57test('generated catalogue seed preserves the complete Pear v3 native delivery contract', () => {
58 assert.match(seedGenerator, /nativeDeliveryStatus/)
59 assert.match(seedGenerator, /nativeDeliveryKind/)
60 assert.match(seedGenerator, /nativeInstallLink/)
61 assert.match(seedGenerator, /nativeProductName/)
62 assert.match(seedGenerator, /nativeTargets/)
63})
test/community-submit.test.js +205 -194
……@@ -1,272 +1,283 @@
1// Unit tests for backend/community-submit.cjs — the pure projection + relay
2// management-request shaping behind the community app-submission feature.
3// Run with `node --test`.
41import test from 'node:test'
52import assert from 'node:assert/strict'
63import communitySubmit from '../backend/community-submit.cjs'
74
85const {
6 MAX_ICON_DATA_CHARS,
7 MAX_INDEX_BYTES,
98 slugify,
9 normalizePearInstallLink,
10 normalizeIconData,
1011 deriveKeyAndLink,
1112 buildSubmissionManifest,
1213 manageRequest,
1314 communityBeeEntry,
1415 buildReviewReport,
15 reviewEvidenceMatches,
16 MAX_INDEX_BYTES
16 reviewEvidenceMatches
1717} = communitySubmit
1818
19// A real 64-hex content key + a known z-base-32 encoding of the SAME bytes, so
20// deriveKeyAndLink's normalizeKey injection is exercised the way index.js wires
21// its z32-decoding normalizeDriveKey.
22const HEX = 'f5fb7500bccd60a976d2b1d24246108f4444a210b9ca591533114dffc089934d'
23// Minimal stand-in: hex passes straight through; a sentinel z32 maps to HEX.
24const Z32 = 'yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy'
19const RECEIPT = 'f5fb7500bccd60a976d2b1d24246108f4444a210b9ca591533114dffc089934d'
20const TARGET = 'a'.repeat(64)
21const Z32 = 'y'.repeat(52)
22const PEAR_KEY = 'a'.repeat(52)
23const PEAR_LINK = `pear://${PEAR_KEY}`
24const PUBLISHER = 'b'.repeat(64)
25const PNG_ICON = `data:image/png;base64,${Buffer.from('89504e470d0a1a0a', 'hex').toString('base64')}`
26
2527const fakeNormalize = (raw) => {
2628 if (/^[0-9a-f]{64}$/i.test(raw)) return raw.toLowerCase()
27 if (raw === Z32) return HEX
29 if (raw === Z32) return TARGET
2830 return raw
2931}
3032
33function nativeInput (overrides = {}) {
34 return {
35 submissionKind: 'pear-v3',
36 name: 'Native Demo',
37 link: PEAR_LINK,
38 version: '3.2.1',
39 productName: 'Native Demo',
40 targets: ['darwin-arm64', 'linux-x64', 'win32-x64'],
41 releaseConfirmed: true,
42 author: 'alice',
43 categories: 'tools, p2p',
44 iconData: PNG_ICON,
45 ...overrides
46 }
47}
48
3149test('slugify produces stable catalogue-safe ids', () => {
3250 assert.equal(slugify('Cool App!!'), 'cool-app')
3351 assert.equal(slugify(' Spaces & Symbols '), 'spaces-symbols')
3452 assert.equal(slugify(''), 'app')
35 assert.equal(slugify(null), 'app')
3653 assert.equal(slugify('A'.repeat(200)).length, 64)
3754})
3855
39test('deriveKeyAndLink: hyper:// link → hex driveKey + hyper link', () => {
40 const r = deriveKeyAndLink('hyper://' + HEX + '/', fakeNormalize)
41 assert.equal(r.error, undefined)
42 assert.equal(r.driveKey, HEX)
43 assert.equal(r.link, 'hyper://' + HEX + '/')
44 assert.equal(r.kind, 'hyper')
45})
46
47test('deriveKeyAndLink: bare 64-hex key → hyper link', () => {
48 const r = deriveKeyAndLink(HEX, fakeNormalize)
49 assert.equal(r.driveKey, HEX)
50 assert.equal(r.kind, 'hyper')
51})
52
53test('deriveKeyAndLink: remote executable app link is rejected', () => {
54 const r = deriveKeyAndLink('pear://' + Z32, fakeNormalize)
55 assert.match(r.error, /not accepted/i)
56})
57
58test('deriveKeyAndLink: rejects remote executable links with paths and queries', () => {
59 const r = deriveKeyAndLink('pear://' + Z32 + '/foo?x=1', fakeNormalize)
60 assert.match(r.error, /not accepted/i)
56test('Pear v3 install links accept only canonical root release identities', () => {
57 assert.deepEqual(normalizePearInstallLink(`${PEAR_LINK}/`), { installLink: PEAR_LINK, key: PEAR_KEY })
58 assert.deepEqual(normalizePearInstallLink(`PEAR://${PEAR_KEY.toUpperCase()}`), { installLink: PEAR_LINK, key: PEAR_KEY })
59 assert.match(normalizePearInstallLink(`${PEAR_LINK}/worker.js`).error, /root Pear release link/i)
60 assert.match(normalizePearInstallLink(`pear://0.42.${PEAR_KEY}`).error, /52-character/i)
61 assert.match(normalizePearInstallLink(`hyper://${PEAR_KEY}`).error, /52-character/i)
6162})
6263
63test('deriveKeyAndLink: rejects junk + empty', () => {
64test('deriveKeyAndLink accepts Hyper targets and routes Pear links to the native form', () => {
65 assert.deepEqual(deriveKeyAndLink(`hyper://${TARGET}/`, fakeNormalize), {
66 driveKey: TARGET,
67 link: `hyper://${TARGET}/`,
68 kind: 'hyper'
69 })
70 assert.equal(deriveKeyAndLink(Z32, fakeNormalize).driveKey, TARGET)
71 assert.match(deriveKeyAndLink(PEAR_LINK, fakeNormalize).error, /Choose Pear v3 app/i)
6472 assert.ok(deriveKeyAndLink('', fakeNormalize).error)
65 assert.ok(deriveKeyAndLink('not-a-key', fakeNormalize).error)
6673})
6774
68test('buildSubmissionManifest: full hyper submission', () => {
69 const { manifest, id, driveKey, error } = buildSubmissionManifest(
70 {
71 name: 'My Community App',
72 link: 'hyper://' + HEX + '/',
73 description: 'A neat thing',
74 author: 'alice',
75 categories: 'tools, social',
76 iconData: 'data:image/svg+xml,<svg/>'
77 },
78 { submittedBy: 'pub'.repeat(21) + 'a', now: 1000, normalizeKey: fakeNormalize }
79 )
80 assert.equal(error, undefined)
81 assert.equal(id, 'my-community-app')
82 assert.equal(driveKey, HEX)
83 assert.equal(manifest.type, 'hypersite')
84 assert.equal(manifest.list, 'community')
85 assert.equal(manifest.submittedAt, 1000)
86 assert.deepEqual(manifest.categories, ['tools', 'social'])
87 assert.equal(manifest.pearLink, undefined) // hyper apps carry no pearLink
88 assert.equal(manifest.iconData, 'data:image/svg+xml,<svg/>')
75test('icon validation accepts bounded image bytes and rejects spoofed or active data', () => {
76 assert.equal(normalizeIconData(PNG_ICON).iconData, PNG_ICON)
77 const safeSvg = `data:image/svg+xml;base64,${Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><rect width="8" height="8"/></svg>').toString('base64')}`
78 assert.equal(normalizeIconData(safeSvg).iconData, safeSvg)
79 const activeSvg = `data:image/svg+xml;base64,${Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>').toString('base64')}`
80 assert.match(normalizeIconData(activeSvg).error, /unsafe SVG/i)
81 assert.match(normalizeIconData('data:image/png;base64,AAAA').error, /do not match/i)
82 assert.match(normalizeIconData('data:text/plain;base64,SGk=').error, /PNG, JPEG/i)
83 assert.match(normalizeIconData('data:image/png;base64,' + 'A'.repeat(MAX_ICON_DATA_CHARS)).error, /too large/i)
8984})
9085
91test('buildSubmissionManifest: remote executable app submission is rejected', () => {
92 const { error } = buildSubmissionManifest(
93 { name: 'Pear Thing', link: 'pear://' + Z32 },
94 { now: 5, normalizeKey: fakeNormalize }
95 )
96 assert.match(error, /not accepted/i)
97})
86test('buildSubmissionManifest creates one review receipt for browsable Hyper content', () => {
87 const result = buildSubmissionManifest({
88 submissionKind: 'hyper',
89 name: 'Community Site',
90 link: `hyper://${TARGET}/`,
91 description: 'A neat site',
92 author: 'alice',
93 categories: 'tools, social, tools',
94 iconData: PNG_ICON
95 }, { submittedBy: PUBLISHER, now: 1000, normalizeKey: fakeNormalize })
9896
99test('buildSubmissionManifest: name required + bad key rejected', () => {
100 assert.ok(buildSubmissionManifest({ link: 'hyper://' + HEX }, { normalizeKey: fakeNormalize }).error)
101 assert.ok(buildSubmissionManifest({ name: 'x', link: 'garbage' }, { normalizeKey: fakeNormalize }).error)
97 assert.equal(result.error, undefined)
98 assert.equal(result.kind, 'hyper')
99 assert.equal(result.driveKey, TARGET)
100 assert.equal(result.receiptId, `community-hyper-${TARGET}`)
101 assert.equal(result.manifest.receiptVersion, 1)
102 assert.equal(result.manifest.submissionKind, 'hyper')
103 assert.equal(result.manifest.type, 'hypersite')
104 assert.deepEqual(result.manifest.categories, ['tools', 'social'])
105 assert.equal(result.manifest.iconData, PNG_ICON)
102106})
103107
104test('buildSubmissionManifest: oversized iconData is dropped', () => {
105 const big = 'data:' + 'a'.repeat(300000)
106 const { manifest } = buildSubmissionManifest(
107 { name: 'Big', link: HEX, iconData: big },
108 { normalizeKey: fakeNormalize }
109 )
110 assert.equal(manifest.iconData, undefined)
108test('buildSubmissionManifest projects a released Pear v3 app into nativeDelivery', () => {
109 const result = buildSubmissionManifest(nativeInput(), { submittedBy: PUBLISHER, now: 2000 })
110 assert.equal(result.error, undefined)
111 assert.equal(result.kind, 'pear-v3')
112 assert.equal(result.driveKey, null)
113 assert.equal(result.installLink, PEAR_LINK)
114 assert.equal(result.receiptId, `community-native-${PEAR_KEY}`)
115 assert.equal(result.manifest.type, 'standalone')
116 assert.equal(result.manifest.link, undefined)
117 assert.equal(result.manifest.driveKey, undefined)
118 assert.deepEqual(result.manifest.nativeDelivery, {
119 status: 'available',
120 kind: 'pear-v3',
121 installLink: PEAR_LINK,
122 productName: 'Native Demo',
123 targets: ['darwin-arm64', 'linux-x64', 'win32-x64']
124 })
125 assert.deepEqual(result.manifest.release, {
126 channel: 'production',
127 publisherConfirmed: true,
128 workflow: 'pear-v3-build-stage-provision-multisig'
129 })
111130})
112131
113test('manageRequest: pending is an authed GET', () => {
114 const r = manageRequest('pending', { baseUrl: 'http://127.0.0.1:9100/', apiKey: 'secret' })
115 assert.equal(r.error, undefined)
116 assert.equal(r.method, 'GET')
117 assert.equal(r.url, 'http://127.0.0.1:9100/api/manage/catalog/pending')
118 assert.equal(r.headers.authorization, 'Bearer secret')
132test('Pear v3 submission fails closed on unreleased, versioned, or incomplete metadata', () => {
133 assert.match(buildSubmissionManifest(nativeInput({ releaseConfirmed: false })).error, /Confirm that this root link/i)
134 assert.match(buildSubmissionManifest(nativeInput({ version: '' })).error, /package version/i)
135 assert.match(buildSubmissionManifest(nativeInput({ targets: [] })).error, /Select at least one/i)
136 assert.match(buildSubmissionManifest(nativeInput({ targets: ['android-x64'] })).error, /Unsupported Pear v3 target/i)
137 assert.match(buildSubmissionManifest(nativeInput({ link: `pear://0.42.${PEAR_KEY}` })).error, /52-character/i)
138 assert.match(buildSubmissionManifest(nativeInput({ productName: '../bad' })).error, /filename-safe/i)
119139})
120140
121test('manageRequest: approve/reject POST a 64-hex appKey', () => {
122 const a = manageRequest('approve', { baseUrl: 'https://relay.example', apiKey: 'k', appKey: HEX.toUpperCase() })
123 assert.equal(a.method, 'POST')
124 assert.equal(a.url, 'https://relay.example/api/manage/catalog/approve')
125 assert.deepEqual(JSON.parse(a.body), { appKey: HEX }) // lowercased
126 const j = manageRequest('reject', { baseUrl: 'https://relay.example', appKey: HEX })
127 assert.equal(j.url, 'https://relay.example/api/manage/catalog/reject')
141test('buildSubmissionManifest reports invalid icons instead of silently dropping them', () => {
142 const result = buildSubmissionManifest(nativeInput({ iconData: 'data:image/png;base64,AAAA' }))
143 assert.match(result.error, /icon bytes/i)
128144})
129145
130test('manageRequest: guards missing url + bad appKey + bad action', () => {
146test('manageRequest shapes authenticated pending and decision calls', () => {
147 const pending = manageRequest('pending', { baseUrl: 'http://127.0.0.1:9100/', apiKey: 'secret' })
148 assert.equal(pending.method, 'GET')
149 assert.equal(pending.url, 'http://127.0.0.1:9100/api/manage/catalog/pending')
150 assert.equal(pending.headers.authorization, 'Bearer secret')
151 const approve = manageRequest('approve', { baseUrl: 'https://relay.example', apiKey: 'k', appKey: RECEIPT.toUpperCase() })
152 assert.equal(approve.method, 'POST')
153 assert.deepEqual(JSON.parse(approve.body), { appKey: RECEIPT })
131154 assert.ok(manageRequest('pending', {}).error)
132 assert.ok(manageRequest('approve', { baseUrl: 'ftp://x', appKey: HEX }).error)
133155 assert.ok(manageRequest('approve', { baseUrl: 'http://x', appKey: 'short' }).error)
134 assert.ok(manageRequest('bogus', { baseUrl: 'http://x' }).error)
135156})
136157
137test('communityBeeEntry: maps a manifest to the app!<id> schema', () => {
138 const { manifest } = buildSubmissionManifest(
139 { name: 'Entry App', link: HEX, description: 'd', author: 'a' },
140 { now: 42, normalizeKey: fakeNormalize }
141 )
142 const { key, value } = communityBeeEntry(manifest)
143 assert.equal(key, 'app!entry-app')
144 assert.equal(value.id, 'entry-app')
145 assert.equal(value.driveKey, HEX)
146 assert.equal(value.publishedAt, 42) // carried from submittedAt
147 assert.equal(value.name, 'Entry App')
158test('communityBeeEntry emits catalogue-native and Hyper shapes without executable top-level links', () => {
159 const native = communityBeeEntry(buildSubmissionManifest(nativeInput(), { now: 42 }).manifest)
160 assert.equal(native.key, 'app!native-demo')
161 assert.equal(native.value.type, 'standalone')
162 assert.equal(native.value.link, undefined)
163 assert.equal(native.value.nativeDelivery.installLink, PEAR_LINK)
164 assert.equal(native.value.iconData, PNG_ICON)
165
166 const hyperManifest = buildSubmissionManifest({ name: 'Site', submissionKind: 'hyper', link: TARGET }, { normalizeKey: fakeNormalize, now: 44 }).manifest
167 const hyper = communityBeeEntry(hyperManifest)
168 assert.equal(hyper.value.type, 'hypersite')
169 assert.equal(hyper.value.driveKey, TARGET)
170 assert.equal(hyper.value.link, `hyper://${TARGET}/`)
148171})
149172
150test('buildReviewReport: eligible web content still requires human acknowledgement', () => {
173test('Hyper review binds the receipt to a separately fetched target drive', () => {
174 const manifest = buildSubmissionManifest({
175 submissionKind: 'hyper',
176 name: 'Reviewed Site',
177 link: TARGET,
178 version: '1.0.0'
179 }, { now: 1000, normalizeKey: fakeNormalize }).manifest
151180 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' },
181 appKey: RECEIPT,
182 pending: { appKey: RECEIPT, publisherPubkey: PUBLISHER, discoveredAt: 1000 },
183 manifest,
184 receiptDriveVersion: 2,
185 targetDriveKey: TARGET,
186 targetDriveVersion: 4,
155187 indexText: '<!doctype html><html><body>Hello</body></html>',
156188 indexBytes: 47,
157 driveVersion: 4,
158 duplicates: []
189 duplicates: [],
190 normalizeKey: fakeNormalize
159191 }, 2000)
160192
161193 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'))
194 assert.equal(report.submissionKind, 'hyper')
195 assert.equal(report.previewUrl, `hyper://${TARGET}/`)
196 assert.equal(report.manifest.driveKey, TARGET)
197 assert.equal(report.evidence.receiptDriveVersion, 2)
198 assert.equal(report.evidence.targetDriveVersion, 4)
170199 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'))
172200})
173201
174test('buildReviewReport: missing manifest or entrypoint blocks approval', () => {
202test('Pear v3 review validates metadata without pretending to execute or pin the package', () => {
203 const manifest = buildSubmissionManifest(nativeInput(), { now: 1000 }).manifest
175204 const report = buildReviewReport({
176 appKey: HEX,
177 pending: { appKey: HEX, publisherPubkey: 'b'.repeat(64) },
178 driveVersion: 1,
179 indexBytes: 0,
180 manifest: null
181 })
205 appKey: RECEIPT,
206 pending: { appKey: RECEIPT, publisherPubkey: PUBLISHER },
207 manifest,
208 receiptDriveVersion: 3,
209 duplicates: []
210 }, 2000)
182211
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'))
212 assert.equal(report.approvalAllowed, true)
213 assert.equal(report.submissionKind, 'pear-v3')
214 assert.equal(report.previewUrl, '')
215 assert.equal(report.manifest.nativeDelivery.installLink, PEAR_LINK)
216 assert.equal(report.evidence.targetDriveVersion, 0)
217 assert.ok(report.checks.some((check) => check.id === 'native-delivery' && check.status === 'pass'))
218 assert.ok(report.checks.some((check) => check.id === 'install-boundary' && check.status === 'warning'))
219 assert.equal(report.checks.some((check) => check.id === 'entrypoint'), false)
187220})
188221
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' }
222test('review blocks missing receipts and unavailable Hyper targets', () => {
223 const missing = buildReviewReport({
224 appKey: RECEIPT,
225 pending: { appKey: RECEIPT, publisherPubkey: PUBLISHER },
226 receiptDriveVersion: 0,
227 receiptFetchError: 'offline'
217228 })
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})
229 assert.equal(missing.approvalAllowed, false)
230 assert.ok(missing.checks.some((check) => check.id === 'manifest' && check.status === 'block'))
221231
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
232 const manifest = buildSubmissionManifest({ name: 'Offline Site', submissionKind: 'hyper', link: TARGET }, { normalizeKey: fakeNormalize }).manifest
233 const offline = buildReviewReport({
234 appKey: RECEIPT,
235 pending: { appKey: RECEIPT, publisherPubkey: PUBLISHER },
236 receiptDriveVersion: 1,
237 manifest,
238 targetDriveKey: TARGET,
239 targetFetchError: 'not seeded'
230240 })
231 assert.equal(report.approvalAllowed, true)
232 assert.equal(report.checks.find((check) => check.id === 'delivery-boundary').status, 'pass')
241 assert.equal(offline.approvalAllowed, false)
242 assert.ok(offline.checks.some((check) => check.id === 'target-availability' && check.status === 'block'))
243 assert.ok(offline.checks.some((check) => check.id === 'entrypoint' && check.status === 'block'))
233244})
234245
235test('buildReviewReport: duplicates and external behavior are review warnings', () => {
246test('review surfaces duplicates, external behavior, and oversized entrypoints', () => {
247 const manifest = buildSubmissionManifest({ name: 'External', submissionKind: 'hyper', link: TARGET }, { normalizeKey: fakeNormalize }).manifest
236248 const report = buildReviewReport({
237 appKey: HEX,
238 pending: { appKey: HEX, publisherPubkey: 'b'.repeat(64) },
239 driveVersion: 2,
249 appKey: RECEIPT,
250 pending: { appKey: RECEIPT, publisherPubkey: PUBLISHER },
251 receiptDriveVersion: 1,
252 manifest,
253 targetDriveKey: TARGET,
254 targetDriveVersion: 2,
240255 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 }]
256 duplicates: [{ id: 'existing', name: 'Existing', driveKey: TARGET }]
243257 })
244
245258 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'))
248259 assert.deepEqual(report.evidence.externalOrigins, ['https://cdn.example'])
249260 assert.deepEqual(report.evidence.behaviorSignals, ['form submission'])
250})
261 assert.ok(report.checks.some((check) => check.id === 'duplicate' && check.status === 'warning'))
251262
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,
263 const oversized = buildReviewReport({
264 appKey: RECEIPT,
265 pending: { appKey: RECEIPT, publisherPubkey: PUBLISHER },
266 receiptDriveVersion: 1,
267 manifest,
268 targetDriveKey: TARGET,
269 targetDriveVersion: 2,
257270 indexText: '<html></html>',
258 indexBytes: MAX_INDEX_BYTES + 1,
259 manifest: { name: 'Too Large', driveKey: HEX }
271 indexBytes: MAX_INDEX_BYTES + 1
260272 })
261 assert.equal(report.approvalAllowed, false)
262 assert.ok(report.checks.some((check) => check.id === 'entrypoint' && check.status === 'block'))
273 assert.equal(oversized.approvalAllowed, false)
274 assert.ok(oversized.checks.some((check) => check.id === 'entrypoint' && check.status === 'block'))
263275})
264276
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)
277test('reviewEvidenceMatches binds approval to receipt and target versions', () => {
278 const report = { checkedAt: 1234, evidence: { receiptDriveVersion: 9, targetDriveVersion: 4 } }
279 assert.equal(reviewEvidenceMatches({ reviewedAt: 1234, reviewedReceiptDriveVersion: 9, reviewedTargetDriveVersion: 4 }, report), true)
280 assert.equal(reviewEvidenceMatches({ reviewedAt: 1234, reviewedReceiptDriveVersion: 10, reviewedTargetDriveVersion: 4 }, report), false)
281 assert.equal(reviewEvidenceMatches({ reviewedAt: 1234, reviewedReceiptDriveVersion: 9, reviewedTargetDriveVersion: 5 }, report), false)
270282 assert.equal(reviewEvidenceMatches({}, report), false)
271 assert.equal(reviewEvidenceMatches({ reviewedAt: 1234, reviewedDriveVersion: 9 }, null), false)
272283})
test/mobile-source-contract.test.js +12 -5
……@@ -52,7 +52,7 @@ test('mobile Bookmarks, History, and More hub expose local data workflows and st
5252 'removeBookmark(url)',
5353 'onPress={() => onOpen(b.url)}',
5454 'No bookmarks yet',
55 'Bookmark sites while browsing',
55 'Save the current site from Page actions while browsing.',
5656 ])
5757
5858 includesAll(history, [
……@@ -182,22 +182,27 @@ test('mobile site editor supports block editing, theme selection, preview, save,
182182 ])
183183})
184184
185test('mobile native Explore screens preserve safe catalog targets like React Native Explore', { skip: !hasMobileCheckout && 'mobile checkout is not present in this isolated desktop worktree' }, () => {
185test('mobile Explore screens preserve Pear v3 metadata without executing native links', { skip: !hasMobileCheckout && 'mobile checkout is not present in this isolated desktop worktree' }, () => {
186186 const rn = source('app/screens/ExploreScreen.tsx')
187187 const ios = source('ios-native/PearBrowser/Sources/UI/Screens/ExploreScreen.swift')
188188 const android = source('android-native/app/src/main/java/com/pearbrowser/app/ui/screens/ExploreScreen.kt')
189189
190190 includesAll(rn, [
191191 'normalizeEntry',
192 'site.link',
193 'onVisit(site.link)',
192 'normalizeNativeDelivery',
193 'nativeDelivery?: NativeDelivery',
194 'catalogAction',
195 'onVisit(action.target)',
196 'Desktop only',
194197 'filter((site): site is SiteInfo => !!site)',
195198 ])
196199
197200 includesAll(ios, [
198201 'let driveKey: String?',
199202 'let link: String?',
203 'let nativeDelivery: NativeDelivery?',
200204 'normalizeCatalogLink',
205 'normalizeNativeDelivery',
201206 'normalizeDriveKey',
202207 'driveKeyFromHyperLink',
203208 'onVisit(link)',
……@@ -207,10 +212,12 @@ test('mobile native Explore screens preserve safe catalog targets like React Nat
207212 includesAll(android, [
208213 'val driveKey: String?',
209214 'val link: String?',
215 'val nativeDelivery: NativeDelivery?',
210216 'normalizeCatalogLink',
217 'normalizeNativeDelivery',
211218 'normalizeDriveKey',
212219 'driveKeyFromHyperLink',
213 'val target = site.link',
220 'val target = if (site.desktopPackage) null else',
214221 '"pear", "file" ->',
215222 ])
216223})
test/release-packaging.test.js +7 -3
……@@ -269,7 +269,7 @@ test('Pear stage ignore excludes local release/operator scratch files', () => {
269269 assert.ok(ignored.includes('/test'))
270270})
271271
272test('PearBrowser catalogue row is a migration-required native record, not a remote launcher', () => {
272test('PearBrowser catalogue row stays migration-only until its Pear v3 production quorum is released', () => {
273273 const source = catalogSource.apps.find((app) => app.id === 'pearbrowser-desktop')
274274 const seed = SEED_APPS.find((app) => app.name === 'PearBrowser Desktop')
275275 const homepageKey = '03f0060a35451cfb6b68ad1dda1b8474ebb43fd9100071ccf7d67679a83ebb4f'
……@@ -277,13 +277,15 @@ test('PearBrowser catalogue row is a migration-required native record, not a rem
277277 assert.ok(source, 'catalogue source row missing')
278278 assert.ok(seed, 'offline catalogue seed row missing')
279279 assert.equal(source.version, pkg.version)
280 assert.equal(source.type, 'standalone')
280281 assert.equal(source.legacyMigrationId, pkg.upgrade.replace(/^pear:\/\//, ''))
281 assert.equal(source.nativeDelivery?.status, 'migration-required')
282 assert.deepEqual(source.nativeDelivery, { status: 'migration-required' })
282283 assert.equal(source.driveKey, homepageKey)
283284 assert.equal(source.homepage, `hyper://${homepageKey}/`)
284285 assert.equal(source.link, source.homepage)
285286 assert.equal(seed.version, source.version)
286287 assert.equal(seed.nativeDeliveryStatus, source.nativeDelivery.status)
288 assert.equal(seed.nativeInstallLink, undefined)
287289 assert.equal(seed.link, source.link)
288290 assert.equal(seed.driveKey, source.driveKey)
289291 assert.equal(seed.homepage, source.homepage)
……@@ -2254,7 +2256,9 @@ test('release story smoke covers browse, catalogue, local stories, and opt-in si
22542256
22552257test('live catalogue verifier asserts PearBrowser release contract and Peercord provenance metadata', () => {
22562258 assert.match(liveCatalogVerifier, /PearBrowser version mismatch/)
2257 assert.match(liveCatalogVerifier, /PearBrowser link mismatch/)
2259 assert.match(liveCatalogVerifier, /PearBrowser native install link mismatch/)
2260 assert.match(liveCatalogVerifier, /PearBrowser native delivery must be an available Pear v3 release/)
2261 assert.match(liveCatalogVerifier, /PearBrowser migration identity mismatch/)
22582262 assert.match(liveCatalogVerifier, /PearBrowser homepage mismatch/)
22592263 assert.match(liveCatalogVerifier, /Peercord sourceUrl mismatch/)
22602264 assert.match(liveCatalogVerifier, /https:\/\/git\.churchofmalware\.org\/mastercodeon\/Peercord/)
ui/shell.js +140 -23
……@@ -2480,48 +2480,118 @@ function CollaborativeCatalog ({ rpc, C }) {
24802480 `
24812481}
24822482
2483// "Submit your app" — anyone can propose an app for the COMMUNITY catalogue.
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.
2483// "Submit your app" — anyone can propose either released Pear v3 native
2484// delivery or browsable Hyper content. CMD_SUBMIT_APP queues a small catalogue
2485// receipt; Pear distributes native builds and publishers seed Hyper content.
2486// Receipt approval and Community-catalogue publication remain separate gates.
24872487function CommunitySubmit ({ rpc, C }) {
2488 const [submissionKind, setSubmissionKind] = useState('pear-v3')
24882489 const [name, setName] = useState('')
24892490 const [link, setLink] = useState('')
2491 const [version, setVersion] = useState('')
2492 const [productName, setProductName] = useState('')
2493 const [targets, setTargets] = useState([])
2494 const [releaseConfirmed, setReleaseConfirmed] = useState(false)
24902495 const [description, setDescription] = useState('')
24912496 const [author, setAuthor] = useState('')
24922497 const [categories, setCategories] = useState('')
2498 const [iconData, setIconData] = useState('')
2499 const [iconName, setIconName] = useState('')
24932500 const [busy, setBusy] = useState(false)
24942501 const [err, setErr] = useState('')
24952502 const [ok, setOk] = useState('')
24962503
2504 const native = submissionKind === 'pear-v3'
2505 const targetOptions = [
2506 ['darwin-arm64', 'macOS Apple silicon'],
2507 ['darwin-x64', 'macOS Intel'],
2508 ['linux-arm64', 'Linux ARM64'],
2509 ['linux-x64', 'Linux x64'],
2510 ['win32-arm64', 'Windows ARM64'],
2511 ['win32-x64', 'Windows x64']
2512 ]
2513
2514 const switchKind = (kind) => {
2515 setSubmissionKind(kind)
2516 setLink('')
2517 setOk('')
2518 setErr('')
2519 setReleaseConfirmed(false)
2520 }
2521
2522 const toggleTarget = (target) => {
2523 setTargets((current) => current.includes(target)
2524 ? current.filter((value) => value !== target)
2525 : [...current, target])
2526 }
2527
2528 const selectIcon = (event) => {
2529 setErr(''); setIconData(''); setIconName('')
2530 const file = event.target.files && event.target.files[0]
2531 if (!file) return
2532 const allowed = ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/svg+xml']
2533 if (!allowed.includes(file.type)) { setErr('Choose a PNG, JPEG, WebP, GIF, or SVG icon.'); return }
2534 if (file.size > 14 * 1024) { setErr('Keep the icon under 14 KB so it fits the shared catalogue record.'); return }
2535 const reader = new FileReader()
2536 reader.onerror = () => setErr('The icon could not be read.')
2537 reader.onload = () => {
2538 const value = typeof reader.result === 'string' ? reader.result : ''
2539 if (!value || value.length > 20000) { setErr('The encoded icon is too large for the catalogue.'); return }
2540 setIconData(value); setIconName(file.name)
2541 }
2542 reader.readAsDataURL(file)
2543 }
2544
24972545 const submit = async () => {
24982546 setErr(''); setOk('')
24992547 if (!name.trim()) { setErr('App name is required.'); return }
2500 if (!link.trim()) { setErr('Paste a hyper:// link or a drive key.'); return }
2501 if (/^(?:pear|file):\/\//i.test(link.trim())) { setErr('Remote executable app links are not accepted. Submit browsable hyper:// content only.'); return }
2548 if (!link.trim()) { setErr(native ? 'Paste the production pear:// release link.' : 'Paste a hyper:// link or drive key.'); return }
2549 if (native && !version.trim()) { setErr('Enter the version currently published on this Pear release line.'); return }
2550 if (native && targets.length === 0) { setErr('Select every operating-system target included in the release.'); return }
2551 if (native && !releaseConfirmed) { setErr('Confirm that the root link is the seeded production provision or multisig release line.'); return }
2552 if (!native && /^(?:pear|file):\/\//i.test(link.trim())) { setErr('Choose Pear v3 app for native release links.'); return }
25022553 setBusy(true)
25032554 try {
25042555 const res = await rpc.request(C.CMD_SUBMIT_APP, {
2505 name: name.trim(), link: link.trim(), description: description.trim(),
2506 author: author.trim(), categories
2556 submissionKind,
2557 name: name.trim(),
2558 link: link.trim(),
2559 version: version.trim(),
2560 productName: productName.trim() || name.trim(),
2561 targets,
2562 releaseConfirmed,
2563 description: description.trim(),
2564 author: author.trim(),
2565 categories,
2566 iconData
25072567 }, 90000)
25082568 const submittedName = (res && res.manifest && res.manifest.name) || name.trim()
25092569 const relayStatus = res && res.status === 'pending-review'
2510 ? `${res.acceptances} relay${res.acceptances === 1 ? '' : 's'} acknowledged the review request.`
2570 ? `${res.acceptances} relay${res.acceptances === 1 ? '' : 's'} acknowledged the catalogue receipt.`
25112571 : '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.'
25122572 const receiptStatus = res && res.receiptWarning ? ` ${res.receiptWarning}` : ''
25132573 setOk(`Submitted "${submittedName}". ${relayStatus}${receiptStatus} Catalogue publication remains a separate final gate.`)
2514 setName(''); setLink(''); setDescription(''); setAuthor(''); setCategories('')
2574 setName(''); setLink(''); setVersion(''); setProductName(''); setTargets([]); setReleaseConfirmed(false)
2575 setDescription(''); setAuthor(''); setCategories(''); setIconData(''); setIconName('')
25152576 } catch (e) { setErr((e && e.message) || String(e)) } finally { setBusy(false) }
25162577 }
25172578
25182579 return html`
25192580 <div className="community-submit">
25202581 <h2>Submit your app <span className="settings-subtle">→ Community list</span></h2>
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>
2582 <p className="subtitle">Submit release metadata for review. Pear v3 native apps must already be built, staged, provisioned or multisig-gated, and seeded under a stable root <code>pear://</code> production identity. This form does not release or execute the app.</p>
25222583 <div className="settings-card">
25232584 ${err && html`<div className="apps-error">${err}</div>`}
25242585 ${ok && html`<div className="apps-ok">${ok}</div>`}
2586 <div className="community-kind" role="group" aria-label="Submission type">
2587 <button className=${'btn ' + (native ? 'primary' : 'subtle')} onClick=${() => switchKind('pear-v3')}>Pear v3 app</button>
2588 <button className=${'btn ' + (!native ? 'primary' : 'subtle')} onClick=${() => switchKind('hyper')}>Hyper site</button>
2589 </div>
2590 <div className="community-release-note">
2591 ${native
2592 ? html`<span><strong>Pear v3 flow:</strong> <code>pear build</code> → <code>pear stage</code> → <code>pear provision</code> / multisig → keep the root release link seeded.</span>`
2593 : html`<span><strong>Hyper flow:</strong> publish and seed a drive with a root <code>/index.html</code>. The review receipt points to it but does not pin it automatically.</span>`}
2594 </div>
25252595 <div className="settings-row">
25262596 <div className="profile-field">
25272597 <div className="settings-label">App name *</div>
……@@ -2530,10 +2600,33 @@ function CommunitySubmit ({ rpc, C }) {
25302600 </div>
25312601 <div className="settings-row">
25322602 <div className="profile-field">
2533 <div className="settings-label">Link *</div>
2534 <input className="profile-input" placeholder="hyper://… (or a 64-hex / z-base-32 key)" value=${link} onInput=${(e) => setLink(e.target.value)} onKeyDown=${(e) => e.key === 'Enter' && submit()} />
2603 <div className="settings-label">${native ? 'Production Pear release link *' : 'Hyper content link *'}</div>
2604 <input className="profile-input" spellCheck="false" placeholder=${native ? 'pear://<52-character production key>' : 'hyper://… (or a 64-hex / z-base-32 key)'} value=${link} onInput=${(e) => setLink(e.target.value)} />
25352605 </div>
25362606 </div>
2607 ${native && html`
2608 <div className="settings-row">
2609 <div className="profile-field">
2610 <div className="settings-label">Released version *</div>
2611 <input className="profile-input" placeholder="1.2.3" value=${version} onInput=${(e) => setVersion(e.target.value)} />
2612 </div>
2613 <div className="profile-field">
2614 <div className="settings-label">Installed product name *</div>
2615 <input className="profile-input" placeholder=${name.trim() || 'Must match the Pear package'} value=${productName} onInput=${(e) => setProductName(e.target.value)} />
2616 </div>
2617 </div>
2618 <div className="profile-field">
2619 <div className="settings-label">Published targets *</div>
2620 <div className="community-targets">
2621 ${targetOptions.map(([target, label]) => html`
2622 <label key=${target}>
2623 <input type="checkbox" checked=${targets.includes(target)} onChange=${() => toggleTarget(target)} />
2624 <span>${label}</span>
2625 </label>
2626 `)}
2627 </div>
2628 </div>
2629 `}
25372630 <div className="settings-row">
25382631 <div className="profile-field">
25392632 <div className="settings-label">Description</div>
……@@ -2550,8 +2643,28 @@ function CommunitySubmit ({ rpc, C }) {
25502643 <input className="profile-input" placeholder="tools, social" value=${categories} onInput=${(e) => setCategories(e.target.value)} />
25512644 </div>
25522645 </div>
2646 <div className="profile-field">
2647 <div className="settings-label">App icon <span className="settings-subtle">PNG, JPEG, WebP, GIF, or safe SVG · max 14 KB</span></div>
2648 <div className="community-icon-upload">
2649 ${iconData
2650 ? html`<img src=${safeIconSrc(iconData)} alt="Selected app icon" />`
2651 : html`<div className="app-icon app-icon-fallback">${(name || '?').charAt(0)}</div>`}
2652 <label className="btn">
2653 ${iconData ? 'Replace icon' : 'Choose icon'}
2654 <input type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml" onChange=${selectIcon} />
2655 </label>
2656 ${iconName && html`<span className="settings-subtle">${iconName}</span>`}
2657 ${iconData && html`<button className="btn subtle" onClick=${() => { setIconData(''); setIconName('') }}>Remove</button>`}
2658 </div>
2659 </div>
2660 ${native && html`
2661 <label className="community-release-confirm">
2662 <input type="checkbox" checked=${releaseConfirmed} onChange=${(e) => setReleaseConfirmed(e.target.checked)} />
2663 <span>I confirm this root link is the currently seeded production provision or multisig release line, not a versioned stage link.</span>
2664 </label>
2665 `}
25532666 <div className="settings-row">
2554 <button className="btn primary" onClick=${submit} disabled=${busy || !name.trim() || !link.trim()}>${busy ? 'Submitting…' : 'Submit for review'}</button>
2667 <button className="btn primary" onClick=${submit} disabled=${busy || !name.trim() || !link.trim() || (native && (!version.trim() || targets.length === 0 || !releaseConfirmed))}>${busy ? 'Submitting…' : 'Submit catalogue receipt'}</button>
25552668 </div>
25562669 </div>
25572670 </div>
……@@ -2634,7 +2747,8 @@ function ModeratorPanel ({ rpc, C, onPreview }) {
26342747 appKey,
26352748 acknowledged: acknowledged[appKey] === true,
26362749 reviewedAt: report && report.checkedAt,
2637 reviewedDriveVersion: report && report.evidence && report.evidence.driveVersion,
2750 reviewedReceiptDriveVersion: report && report.evidence && report.evidence.receiptDriveVersion,
2751 reviewedTargetDriveVersion: report && report.evidence && report.evidence.targetDriveVersion,
26382752 note,
26392753 reason
26402754 }, 60000)
……@@ -2645,8 +2759,8 @@ function ModeratorPanel ({ rpc, C, onPreview }) {
26452759 ? res.auditWarning
26462760 : (approve
26472761 ? (res && res.promoted && res.promoted.deferred
2648 ? 'Relay pin approved. Community catalogue publication is still pending.'
2649 : 'Relay pin approved and audited.')
2762 ? 'Catalogue receipt approved. Community catalogue publication is still pending.'
2763 : 'Catalogue receipt approved and audited.')
26502764 : 'Rejected with an audit reason.'))
26512765 } catch (e) { setErr(e.message) } finally { setBusy(null) }
26522766 }
……@@ -2667,11 +2781,11 @@ function ModeratorPanel ({ rpc, C, onPreview }) {
26672781 <div className="settings-card">
26682782 ${err && html`<div className="apps-error">${err}</div>`}
26692783 ${notice && html`<div className="apps-ok">${notice}</div>`}
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>
2784 <p className="subtitle">Review signed catalogue receipts. A receipt points to separately distributed Hyper content or a Pear v3 production identity; native release bytes stay on Pear's release line. Approval authorizes receipt replication only, while shared catalogue publication remains a separate release step.</p>
26712785 <div className="mod-process">
26722786 <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>
2787 <span><strong>2</strong> Fetch receipt + target</span><span>→</span>
2788 <span><strong>3</strong> Review + decide</span><span>→</span>
26752789 <span><strong>4</strong> Publish catalogue</span>
26762790 </div>
26772791 <div className="settings-row">
……@@ -2714,7 +2828,7 @@ function ModeratorPanel ({ rpc, C, onPreview }) {
27142828 </div>
27152829 <div className="mod-review-actions">
27162830 <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>
2831 ${report && report.previewUrl && html`<button className="btn small subtle" onClick=${() => onPreview && onPreview(report.previewUrl)} disabled=${!!busy}>Open target preview</button>`}
27182832 </div>
27192833 ${report && html`
27202834 <div className="mod-summary">
……@@ -2727,6 +2841,7 @@ function ModeratorPanel ({ rpc, C, onPreview }) {
27272841 <strong>${report.manifest.name}</strong>${report.manifest.version ? ` · v${report.manifest.version}` : ''}${report.manifest.author ? ` · ${report.manifest.author}` : ''}
27282842 ${report.manifest.description && html`<div>${report.manifest.description}</div>`}
27292843 ${report.manifest.categories && report.manifest.categories.length > 0 && html`<div className="settings-subtle">${report.manifest.categories.join(' · ')}</div>`}
2844 ${report.manifest.nativeDelivery?.installLink && html`<div className="mod-key">${report.manifest.nativeDelivery.installLink}</div>`}
27302845 </div>
27312846 `}
27322847 <div className="mod-checks">
……@@ -2739,7 +2854,9 @@ function ModeratorPanel ({ rpc, C, onPreview }) {
27392854 </div>
27402855 <label className="mod-ack">
27412856 <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.
2857 ${report.submissionKind === 'pear-v3'
2858 ? 'I independently checked the publisher and Pear release metadata, reviewed every warning, and understand that receipt checks are not a safety endorsement.'
2859 : 'I opened the target preview, reviewed every warning, and understand that automated checks are not a safety endorsement.'}
27432860 </label>
27442861 `}
27452862 <div className="profile-field">
……@@ -2751,7 +2868,7 @@ function ModeratorPanel ({ rpc, C, onPreview }) {
27512868 <input className="profile-input" placeholder="Required only when rejecting" value=${reasons[p.appKey] || ''} onInput=${(e) => setReasons((current) => ({ ...current, [p.appKey]: e.target.value }))} />
27522869 </div>
27532870 <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>
2871 <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 receipt'}</button>
27552872 <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>
27562873 </div>
27572874 </div>