| … | … | @@ -4,6 +4,55 @@ |
| 4 | 4 | > only to explain past releases; they are not current installation, launch, or |
| 5 | 5 | > recovery instructions. Current native delivery requires a verified package. |
| 6 | 6 | |
| 7 | ## v0.8.0 — 2026-07-29 | |
| 8 | ||
| 9 | Pear v3 application delivery and catalogue submission release. PearBrowser now | |
| 10 | separates native package installation from browsable Hyper content end to end, | |
| 11 | from 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 | ||
| 7 | 56 | ## v0.7.1 — 2026-07-16 |
| 8 | 57 | |
| 9 | 58 | Corrective desktop transport release. The backend RPC handlers were healthy, |
| … | … | @@ -4,21 +4,21 @@ A local-first peer-to-peer browser, app store, search engine, naming layer, Nost |
| 4 | 4 | |
| 5 | 5 | **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. |
| 6 | 6 | |
| 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. | |
| 8 | 8 | |
| 9 | 9 | **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). |
| 10 | 10 | |
| 11 | 11 | ## Install it |
| 12 | 12 | |
| 13 | 13 | Primary 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 | |
| 15 | 15 | and 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), | |
| 17 | 17 | follow the [native install guide](./docs/INSTALL_NATIVE_PACKAGES.md), or resolve |
| 18 | 18 | the recommended asset for your machine from a source checkout: |
| 19 | 19 | |
| 20 | 20 | ```sh |
| 21 | npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop | |
| 21 | npm run resolve:native-release -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop | |
| 22 | 22 | ``` |
| 23 | 23 | |
| 24 | 24 | The current package targets match the `cmake-pear` appling toolchain: |
| … | … | @@ -27,9 +27,10 @@ The current package targets match the `cmake-pear` appling toolchain: |
| 27 | 27 | - Windows: `PearBrowser-<version>-windows-x64.msix` now |
| 28 | 28 | - Linux: `PearBrowser-<version>-linux-x64.AppImage` now, distro packages such as `.deb` later if demand warrants them |
| 29 | 29 | |
| 30 | The previous stable `pear://` key is a **legacy migration record**, not an | |
| 31 | install or launch instruction. PearBrowser never passes a remote link to | |
| 32 | `PearRuntime.run()`. Keep legacy data intact, install the native package for | |
| 30 | The retained PearBrowser `upgrade` key is a **migration record** until the v3 | |
| 31 | production provision/multisig quorum is independently verified; it is not an | |
| 32 | install instruction in the catalogue. PearBrowser never passes a remote link | |
| 33 | to `PearRuntime.run()`. Keep legacy data intact, install the native package for | |
| 33 | 34 | your platform, and use the migration guidance in |
| 34 | 35 | [docs/PEAR_V3_MIGRATION.md](./docs/PEAR_V3_MIGRATION.md). |
| 35 | 36 | |
| … | … | @@ -222,29 +223,29 @@ human approval of clean-install, upgrade, rollback, and data-continuity proof. |
| 222 | 223 | |
| 223 | 224 | ## Distribution |
| 224 | 225 | |
| 225 | The `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. | |
| 226 | The `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. | |
| 226 | 227 | |
| 227 | 228 | Current 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. |
| 228 | 229 | |
| 229 | 230 | ```sh |
| 230 | npm run check:appling-release -- --tag v0.7.1 | |
| 231 | npm run check:appling-release -- --tag v0.8.0 | |
| 231 | 232 | npm run check:linux-appimage-metadata |
| 232 | npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop | |
| 233 | npm run -s generate:native-signing-secret-plan -- --repo bigdestiny2/pearbrowser-desktop --tag v0.7.1 --source-ref <release-commit> | |
| 234 | npm run -s generate:native-install-snippet -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop | |
| 235 | npm run -s generate:native-install-guide -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop | |
| 236 | npm run -s generate:native-install-smoke-plan -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> | |
| 237 | npm run generate:package-manager-manifests -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --trust-mode package-proof | |
| 233 | npm run resolve:native-release -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop | |
| 234 | npm run -s generate:native-signing-secret-plan -- --repo bigdestiny2/pearbrowser-desktop --tag v0.8.0 --source-ref <release-commit> | |
| 235 | npm run -s generate:native-install-snippet -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop | |
| 236 | npm run -s generate:native-install-guide -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop | |
| 237 | npm run -s generate:native-install-smoke-plan -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> | |
| 238 | npm run generate:package-manager-manifests -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop --trust-mode package-proof | |
| 238 | 239 | npm run check:native-signing -- --require-public-trust --secret-source github --repo bigdestiny2/pearbrowser-desktop |
| 239 | npm run check:public-trust-readiness -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github | |
| 240 | npm run -s generate:public-trust-operator-report -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github | |
| 240 | npm run check:public-trust-readiness -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github | |
| 241 | npm run -s generate:public-trust-operator-report -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github | |
| 241 | 242 | npm run -s generate:release-evidence-handoff |
| 242 | 243 | cd appling |
| 243 | 244 | npm ci |
| 244 | 245 | npm run generate |
| 245 | 246 | npm run build |
| 246 | 247 | cd .. |
| 247 | npm run package:appling -- --tag v0.7.1 | |
| 248 | npm run package:appling -- --tag v0.8.0 | |
| 248 | 249 | ``` |
| 249 | 250 | |
| 250 | 251 | Code signing is per-platform: |
| … | … | @@ -332,7 +332,7 @@ add_pear_appling( |
| 332 | 332 | pearbrowser_desktop |
| 333 | 333 | ID "tco5k7h38uoxatedp1wongdbhjxow1x7jiwm3t1i9cujbebhsbty" |
| 334 | 334 | NAME "PearBrowser" |
| 335 | VERSION 0.7.1 | |
| 335 | VERSION 0.8.0 | |
| 336 | 336 | AUTHOR "pearbrowser-desktop contributors" |
| 337 | 337 | DESCRIPTION "P2P browser, app store, and publishing platform — no servers required." |
| 338 | 338 |
| … | … | @@ -20,7 +20,7 @@ |
| 20 | 20 | </categories> |
| 21 | 21 | <url type="homepage">https://github.com/bigdestiny2/pearbrowser-desktop</url> |
| 22 | 22 | <releases> |
| 23 | <release version="0.7.1" date="2026-07-16"/> | |
| 23 | <release version="0.8.0" date="2026-07-29"/> | |
| 24 | 24 | <release version="0.7.0" date="2026-07-16"/> |
| 25 | 25 | <release version="0.6.1" date="2026-07-15"/> |
| 26 | 26 | <release version="0.6.0" date="2026-07-13"/> |
| … | … | @@ -28,7 +28,7 @@ module.exports = { |
| 28 | 28 | "p2p" |
| 29 | 29 | ], |
| 30 | 30 | "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", | |
| 32 | 32 | "homepage": "hyper://03f0060a35451cfb6b68ad1dda1b8474ebb43fd9100071ccf7d67679a83ebb4f/", |
| 33 | 33 | "sourceUrl": "https://github.com/bigdestiny2/pearbrowser-desktop", |
| 34 | 34 | "license": "MIT", |
| … | … | @@ -1,24 +1,25 @@ |
| 1 | 1 | 'use strict' |
| 2 | 2 | |
| 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. | |
| 16 | 6 | |
| 17 | 7 | const HEX64 = /^[0-9a-f]{64}$/i |
| 8 | const PEAR_KEY = /^[13-9a-km-uw-z]{52}$/ | |
| 9 | const SAFE_PRODUCT_NAME = /^[a-zA-Z0-9][a-zA-Z0-9 ._()+@-]{0,119}$/ | |
| 18 | 10 | const MAX_INDEX_BYTES = 2 * 1024 * 1024 |
| 11 | const MAX_ICON_DATA_CHARS = 20000 | |
| 19 | 12 | const MAX_REVIEW_TEXT = 500 |
| 13 | const NATIVE_TARGETS = new Set([ | |
| 14 | 'darwin-arm64', | |
| 15 | 'darwin-x64', | |
| 16 | 'linux-arm64', | |
| 17 | 'linux-x64', | |
| 18 | 'win32-arm64', | |
| 19 | 'win32-x64' | |
| 20 | ]) | |
| 21 | const ICON_MIMES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/svg+xml']) | |
| 20 | 22 | |
| 21 | /** Slug an arbitrary string into a stable, catalogue-safe app id. */ | |
| 22 | 23 | function slugify (s) { |
| 23 | 24 | const out = String(s == null ? '' : s) |
| 24 | 25 | .toLowerCase() |
| … | … | @@ -29,105 +30,188 @@ function slugify (s) { |
| 29 | 30 | return out || 'app' |
| 30 | 31 | } |
| 31 | 32 | |
| 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 | */ | |
| 33 | function boundedText (value, max = MAX_REVIEW_TEXT) { | |
| 34 | return String(value == null ? '' : value).trim().slice(0, max) | |
| 35 | } | |
| 36 | ||
| 37 | function 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 | ||
| 40 | 51 | function deriveKeyAndLink (rawInput, normalizeKey = (x) => x) { |
| 41 | 52 | const raw = String(rawInput == null ? '' : rawInput).trim() |
| 42 | 53 | 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 | } | |
| 43 | 64 | |
| 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' | |
| 65 | function 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 | ||
| 72 | function 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 | ||
| 83 | function 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) | |
| 54 | 95 | } |
| 96 | return false | |
| 97 | } | |
| 55 | 98 | |
| 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.' } | |
| 99 | function 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.` } | |
| 58 | 104 | } |
| 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]}` } | |
| 60 | 113 | } |
| 61 | 114 | |
| 62 | 115 | /** |
| 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. | |
| 67 | 117 | * |
| 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. | |
| 69 | 122 | */ |
| 70 | 123 | function buildSubmissionManifest (input = {}, ctx = {}) { |
| 71 | const name = String(input.name == null ? '' : input.name).trim() | |
| 124 | const name = boundedText(input.name, 160) | |
| 72 | 125 | 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.' } | |
| 86 | 130 | |
| 87 | 131 | const id = slugify(input.id || name) |
| 88 | 132 | 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, | |
| 91 | 140 | 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), | |
| 98 | 145 | categories, |
| 99 | type: 'hypersite', | |
| 100 | 146 | list: 'community', |
| 101 | 147 | submittedBy: ctx.submittedBy || null, |
| 102 | 148 | submittedAt: now |
| 103 | 149 | } |
| 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}` | |
| 108 | 205 | } |
| 109 | return { manifest, id, driveKey: derived.driveKey } | |
| 110 | 206 | } |
| 111 | 207 | |
| 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 | */ | |
| 120 | 208 | function manageRequest (action, opts = {}) { |
| 121 | 209 | const base = String(opts.baseUrl || '').trim().replace(/\/+$/, '') |
| 122 | 210 | if (!base) return { error: 'Relay management URL is not configured. Set it in the Moderator panel.' } |
| 123 | 211 | if (!/^https?:\/\//i.test(base)) return { error: 'Relay management URL must start with http:// or https://' } |
| 124 | ||
| 125 | 212 | const headers = { 'content-type': 'application/json', accept: 'application/json' } |
| 126 | 213 | 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 } | |
| 131 | 215 | if (action === 'approve' || action === 'reject') { |
| 132 | 216 | const appKey = String(opts.appKey || '').toLowerCase() |
| 133 | 217 | if (!HEX64.test(appKey)) return { error: action + ' needs a 64-hex appKey.' } |
| … | … | @@ -141,41 +225,54 @@ function manageRequest (action, opts = {}) { |
| 141 | 225 | return { error: 'Unknown moderation action: ' + action } |
| 142 | 226 | } |
| 143 | 227 | |
| 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 | */ | |
| 228 | function 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 | ||
| 149 | 246 | function 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) | |
| 151 | 249 | const entry = { |
| 152 | 250 | 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', | |
| 160 | 257 | publishedAt: Number.isFinite(manifest.submittedAt) ? manifest.submittedAt : now |
| 161 | 258 | } |
| 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 | |
| 163 | 269 | return { key: 'app!' + id, value: entry } |
| 164 | 270 | } |
| 165 | 271 | |
| 166 | function boundedText (value, max = MAX_REVIEW_TEXT) { | |
| 167 | return String(value == null ? '' : value).trim().slice(0, max) | |
| 168 | } | |
| 169 | ||
| 170 | 272 | function reviewCheck (id, label, status, detail) { |
| 171 | 273 | return { id, label, status, detail: boundedText(detail, 1000) } |
| 172 | 274 | } |
| 173 | 275 | |
| 174 | function manifestTarget (manifest = {}) { | |
| 175 | if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return '' | |
| 176 | return manifest.link || manifest.hyperUrl || manifest.url || manifest.driveKey || manifest.key || '' | |
| 177 | } | |
| 178 | ||
| 179 | 276 | function externalOriginsFromHtml (html) { |
| 180 | 277 | const origins = new Set() |
| 181 | 278 | const source = String(html || '').slice(0, MAX_INDEX_BYTES) |
| … | … | @@ -193,7 +290,8 @@ function duplicateSummary (duplicates) { |
| 193 | 290 | id: boundedText(entry && entry.id, 80), |
| 194 | 291 | name: boundedText(entry && entry.name, 160), |
| 195 | 292 | 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) | |
| 197 | 295 | })) |
| 198 | 296 | } |
| 199 | 297 | |
| … | … | @@ -201,130 +299,138 @@ function reviewEvidenceMatches (input = {}, report = null) { |
| 201 | 299 | if (!report || typeof report !== 'object') return false |
| 202 | 300 | const evidence = report.evidence && typeof report.evidence === 'object' ? report.evidence : {} |
| 203 | 301 | 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) && | |
| 205 | 304 | Number.isFinite(Number(report.checkedAt)) && |
| 206 | Number.isFinite(Number(evidence.driveVersion)) | |
| 305 | Number.isFinite(Number(evidence.receiptDriveVersion)) | |
| 207 | 306 | } |
| 208 | 307 | |
| 209 | /** | |
| 210 | * Turn fetched drive evidence into a deterministic moderation report. | |
| 211 | * | |
| 212 | * The I/O stays in backend/index.js. This helper deliberately distinguishes | |
| 213 | * automated blockers from warnings that still need a human preview. Passing | |
| 214 | * these checks means "eligible for an operator decision", not "safe code". | |
| 215 | */ | |
| 216 | 308 | function buildReviewReport (input = {}, now = Date.now()) { |
| 217 | 309 | 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() | |
| 219 | 311 | const publisherPubkey = boundedText(pending.publisherPubkey, 64).toLowerCase() |
| 220 | 312 | const manifest = input.manifest && typeof input.manifest === 'object' && !Array.isArray(input.manifest) |
| 221 | 313 | ? input.manifest |
| 222 | 314 | : null |
| 315 | const receiptDriveVersion = Number.isFinite(input.receiptDriveVersion) ? input.receiptDriveVersion : 0 | |
| 316 | const targetDriveVersion = Number.isFinite(input.targetDriveVersion) ? input.targetDriveVersion : 0 | |
| 223 | 317 | const indexText = String(input.indexText || '').slice(0, MAX_INDEX_BYTES) |
| 224 | 318 | const indexBytes = Number.isFinite(input.indexBytes) ? input.indexBytes : Buffer.byteLength(indexText) |
| 225 | const driveVersion = Number.isFinite(input.driveVersion) ? input.driveVersion : 0 | |
| 226 | 319 | const duplicates = duplicateSummary(input.duplicates) |
| 227 | 320 | const checks = [] |
| 228 | 321 | |
| 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.')) | |
| 233 | 325 | 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.')) | |
| 237 | 328 | 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.')) | |
| 239 | 330 | } |
| 240 | 331 | |
| 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.')) | |
| 245 | 336 | } 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}.`)) | |
| 258 | 338 | } |
| 259 | 339 | |
| 260 | 340 | 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))) | |
| 262 | 342 | } 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.')) | |
| 264 | 344 | } 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.')) | |
| 266 | 348 | } 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)}”.`)) | |
| 268 | 350 | } |
| 269 | 351 | |
| 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.')) | |
| 287 | 396 | } |
| 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.')) | |
| 294 | 402 | } 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(', ')}.`)) | |
| 296 | 406 | } |
| 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.')) | |
| 297 | 418 | } |
| 298 | 419 | |
| 299 | 420 | 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.`)) | |
| 317 | 423 | } 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.')) | |
| 319 | 425 | } |
| 320 | 426 | |
| 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.')) | |
| 322 | 430 | |
| 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 || '')) | |
| 326 | 432 | 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.')) | |
| 328 | 434 | } |
| 329 | 435 | |
| 330 | 436 | const summary = checks.reduce((counts, check) => { |
| … | … | @@ -333,30 +439,23 @@ function buildReviewReport (input = {}, now = Date.now()) { |
| 333 | 439 | }, { pass: 0, warning: 0, block: 0 }) |
| 334 | 440 | |
| 335 | 441 | return { |
| 336 | appKey, | |
| 442 | appKey: receiptKey, | |
| 443 | receiptKey, | |
| 337 | 444 | publisherPubkey, |
| 338 | previewUrl: HEX64.test(appKey) ? `hyper://${appKey}/` : '', | |
| 445 | submissionKind: kind || 'unknown', | |
| 446 | previewUrl, | |
| 339 | 447 | status: summary.block ? 'blocked' : 'review-needed', |
| 340 | 448 | approvalAllowed: summary.block === 0, |
| 341 | 449 | requiresAcknowledgement: summary.warning > 0, |
| 342 | 450 | checkedAt: now, |
| 343 | 451 | summary, |
| 344 | 452 | 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, | |
| 358 | 454 | evidence: { |
| 359 | driveVersion, | |
| 455 | driveVersion: kind === 'hyper' ? targetDriveVersion : receiptDriveVersion, | |
| 456 | receiptDriveVersion, | |
| 457 | targetDriveVersion, | |
| 458 | targetDriveKey: boundedText(input.targetDriveKey, 64).toLowerCase(), | |
| 360 | 459 | indexBytes, |
| 361 | 460 | externalOrigins, |
| 362 | 461 | behaviorSignals, |
| … | … | @@ -367,8 +466,13 @@ function buildReviewReport (input = {}, now = Date.now()) { |
| 367 | 466 | |
| 368 | 467 | module.exports = { |
| 369 | 468 | HEX64, |
| 469 | PEAR_KEY, | |
| 370 | 470 | MAX_INDEX_BYTES, |
| 471 | MAX_ICON_DATA_CHARS, | |
| 472 | NATIVE_TARGETS: [...NATIVE_TARGETS], | |
| 371 | 473 | slugify, |
| 474 | normalizePearInstallLink, | |
| 475 | normalizeIconData, | |
| 372 | 476 | deriveKeyAndLink, |
| 373 | 477 | buildSubmissionManifest, |
| 374 | 478 | manageRequest, |
| … | … | @@ -183,11 +183,11 @@ const CMD_NOSTR_QUERY = 192 // { filter } → { events } (NIP-01 filter o |
| 183 | 183 | // relay pin requests. Approval authorizes replication only; publishing into the |
| 184 | 184 | // shared community Hyperbee is a separate operator step. See |
| 185 | 185 | // backend/community-submit.cjs + ui/shell.js (submit form + panel). |
| 186 | const CMD_SUBMIT_APP = 210 // { name, link|driveKey, description?, author?, categories?, iconData? } → publish receipt + seed → { id, status:'pending-review'|'awaiting-relay' } | |
| 186 | const CMD_SUBMIT_APP = 210 // { submissionKind:'pear-v3'|'hyper', name, link, version?, productName?, targets?, iconData? } → queue one receipt → status | |
| 187 | 187 | const CMD_MOD_PENDING = 211 // → { pending: [...] } pulled from the relay review queue |
| 188 | const CMD_MOD_APPROVE = 212 // { appKey, acknowledged, reviewedAt, reviewedDriveVersion, note } → guarded relay approval + local audit | |
| 188 | const CMD_MOD_APPROVE = 212 // { appKey, acknowledged, reviewedAt, reviewedReceiptDriveVersion, reviewedTargetDriveVersion, note } → guarded receipt approval + local audit | |
| 189 | 189 | const CMD_MOD_REJECT = 213 // { appKey, reason, note? } → relay reject + local audit |
| 190 | const CMD_MOD_REVIEW = 214 // { appKey } → fetch content + manifest and return due-diligence evidence | |
| 190 | const CMD_MOD_REVIEW = 214 // { appKey } → fetch receipt + optional Hyper target and return due-diligence evidence | |
| 191 | 191 | |
| 192 | 192 | // Browser chrome-owned local AI. Unlike /api/ai/* these commands are not |
| 193 | 193 | // callable by Hyperdrive pages: only PearBrowser's renderer can use them. |
| … | … | @@ -935,27 +935,23 @@ async function reviewPendingSubmission (appKey, pending = {}, opts = {}) { |
| 935 | 935 | const cached = moderationReviewCache.get(normalizedKey) |
| 936 | 936 | if (!opts.force && cached && Date.now() - cached.checkedAt < MODERATION_REVIEW_CACHE_MS) return cached |
| 937 | 937 | |
| 938 | let driveVersion = 0 | |
| 938 | let receiptDriveVersion = 0 | |
| 939 | let targetDriveVersion = 0 | |
| 940 | let targetDriveKey = '' | |
| 939 | 941 | let indexBuf = null |
| 940 | 942 | let indexBytes = 0 |
| 941 | 943 | let manifestBuf = null |
| 942 | 944 | let manifest = null |
| 943 | 945 | let manifestError = '' |
| 944 | let fetchError = '' | |
| 946 | let receiptFetchError = '' | |
| 947 | let targetFetchError = '' | |
| 945 | 948 | |
| 946 | 949 | 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 | |
| 950 | 953 | 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') | |
| 959 | 955 | manifestBuf = manifestFile.buffer |
| 960 | 956 | if (manifestFile.tooLarge) { |
| 961 | 957 | manifestBuf = null |
| … | … | @@ -964,9 +960,7 @@ async function reviewPendingSubmission (appKey, pending = {}, opts = {}) { |
| 964 | 960 | } catch (err) { |
| 965 | 961 | if (!/timed out/i.test(err && err.message)) manifestError = err && err.message ? err.message : String(err) |
| 966 | 962 | } |
| 967 | } catch (err) { | |
| 968 | fetchError = err && err.message ? err.message : String(err) | |
| 969 | } | |
| 963 | } catch (err) { receiptFetchError = err && err.message ? err.message : String(err) } | |
| 970 | 964 | |
| 971 | 965 | if (manifestBuf) { |
| 972 | 966 | if (manifestBuf.length > MAX_REVIEW_MANIFEST_BYTES) { |
| … | … | @@ -978,11 +972,34 @@ async function reviewPendingSubmission (appKey, pending = {}, opts = {}) { |
| 978 | 972 | } |
| 979 | 973 | } |
| 980 | 974 | |
| 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 | ||
| 981 | 993 | const duplicates = catalogManager |
| 982 | 994 | ? 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 | } | |
| 983 | 1000 | const driveKey = normalizeDriveKey(String((app && app.driveKey) || '')) |
| 984 | 1001 | const linkKey = driveKeyFromUrl(app && app.link) |
| 985 | return driveKey === normalizedKey || linkKey === normalizedKey | |
| 1002 | return !!targetDriveKey && (driveKey === targetDriveKey || linkKey === targetDriveKey) | |
| 986 | 1003 | }) |
| 987 | 1004 | : [] |
| 988 | 1005 | |
| … | … | @@ -993,9 +1010,12 @@ async function reviewPendingSubmission (appKey, pending = {}, opts = {}) { |
| 993 | 1010 | manifestError, |
| 994 | 1011 | indexText: indexBuf ? Buffer.from(indexBuf).toString('utf8') : '', |
| 995 | 1012 | indexBytes, |
| 996 | driveVersion, | |
| 1013 | receiptDriveVersion, | |
| 1014 | targetDriveVersion, | |
| 1015 | targetDriveKey, | |
| 997 | 1016 | duplicates, |
| 998 | fetchError, | |
| 1017 | receiptFetchError, | |
| 1018 | targetFetchError, | |
| 999 | 1019 | normalizeKey: normalizeDriveKey |
| 1000 | 1020 | }, Date.now()) |
| 1001 | 1021 | moderationReviewCache.set(normalizedKey, report) |
| … | … | @@ -1090,43 +1110,42 @@ rpc.handle(C.CMD_SUBMIT_APP, async (data = {}) => { |
| 1090 | 1110 | submittedBy, now: Date.now(), normalizeKey: normalizeDriveKey |
| 1091 | 1111 | }) |
| 1092 | 1112 | if (built.error) throw new Error(built.error) |
| 1093 | const { manifest, id, driveKey } = built | |
| 1113 | const { manifest, id, kind, driveKey, installLink, receiptId } = built | |
| 1094 | 1114 | |
| 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. | |
| 1097 | 1120 | let manifestKey = null |
| 1098 | let receiptWarning = '' | |
| 1121 | let acceptances = 0 | |
| 1099 | 1122 | try { |
| 1100 | 1123 | const mdrive = await hiveRelay.publish( |
| 1101 | 1124 | [{ 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 } | |
| 1103 | 1126 | ) |
| 1104 | 1127 | 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)) | |
| 1106 | 1129 | } |
| 1130 | acceptances = Number(mdrive && mdrive.replicas && mdrive.replicas.accepted) || 0 | |
| 1107 | 1131 | } catch (err) { |
| 1108 | 1132 | 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)) | |
| 1121 | 1134 | } |
| 1135 | if (!manifestKey) throw new Error('The catalogue receipt publisher returned no valid drive key.') | |
| 1122 | 1136 | |
| 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}`) | |
| 1124 | 1141 | return { |
| 1125 | 1142 | ok: true, |
| 1126 | 1143 | id, |
| 1127 | 1144 | driveKey, |
| 1145 | installLink, | |
| 1146 | kind, | |
| 1128 | 1147 | manifestKey, |
| 1129 | receiptPublished: !!manifestKey, | |
| 1148 | receiptPublished: true, | |
| 1130 | 1149 | receiptWarning, |
| 1131 | 1150 | acceptances, |
| 1132 | 1151 | status: acceptances > 0 ? 'pending-review' : 'awaiting-relay', |
| … | … | @@ -1187,20 +1206,22 @@ rpc.handle(C.CMD_MOD_APPROVE, async (data = {}) => { |
| 1187 | 1206 | const pending = moderationPendingByKey.get(appKey) |
| 1188 | 1207 | if (!pending) throw new Error('This app is not in the loaded relay queue. Refresh pending submissions before approving it.') |
| 1189 | 1208 | const cachedReview = moderationReviewCache.get(appKey) |
| 1190 | const reviewedDriveVersion = Number(data.reviewedDriveVersion) | |
| 1209 | const reviewedReceiptDriveVersion = Number(data.reviewedReceiptDriveVersion) | |
| 1210 | const reviewedTargetDriveVersion = Number(data.reviewedTargetDriveVersion || 0) | |
| 1191 | 1211 | if (!communitySubmit.reviewEvidenceMatches(data, cachedReview)) { |
| 1192 | 1212 | throw new Error('The due-diligence evidence does not match this approval. Re-run the review before approving.') |
| 1193 | 1213 | } |
| 1194 | 1214 | 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.') | |
| 1197 | 1218 | } |
| 1198 | 1219 | if (!review.approvalAllowed) { |
| 1199 | 1220 | const blockers = review.checks.filter((check) => check.status === 'block').map((check) => check.label).join(', ') |
| 1200 | 1221 | throw new Error('Due diligence blocked approval: ' + (blockers || 'review evidence failed') + '.') |
| 1201 | 1222 | } |
| 1202 | 1223 | 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.') | |
| 1204 | 1225 | } |
| 1205 | 1226 | const note = boundedReviewText(data.note, 1000) |
| 1206 | 1227 | if (!note) throw new Error('Record a reviewer note describing the due diligence performed before approval.') |
| … | … | @@ -1,8 +1,8 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "PearBrowser Network", |
| 3 | 3 | "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", | |
| 6 | 6 | "publisher": "bigdestiny2", |
| 7 | 7 | "apps": [ |
| 8 | 8 | { |
| … | … | @@ -10,13 +10,14 @@ |
| 10 | 10 | "name": "PearBrowser Desktop", |
| 11 | 11 | "description": "P2P browser, decentralized app store, and site publisher. macOS / Windows / Linux. Talks to peers directly over Hyperswarm. Pinned 24/7 on HiveRelay.", |
| 12 | 12 | "author": "bigdestiny2", |
| 13 | "version": "0.7.1", | |
| 13 | "version": "0.8.0", | |
| 14 | 14 | "categories": [ |
| 15 | 15 | "browser", |
| 16 | 16 | "tools", |
| 17 | 17 | "p2p" |
| 18 | 18 | ], |
| 19 | 19 | "publishedAt": "2026-04-28T00:00:00Z", |
| 20 | "type": "standalone", | |
| 20 | 21 | "legacyMigrationId": "tco5k7h38uoxatedp1wongdbhjxow1x7jiwm3t1i9cujbebhsbty", |
| 21 | 22 | "nativeDelivery": { "status": "migration-required" }, |
| 22 | 23 | "driveKey": "03f0060a35451cfb6b68ad1dda1b8474ebb43fd9100071ccf7d67679a83ebb4f", |
| … | … | @@ -1,82 +1,115 @@ |
| 1 | # Community catalogue review process | |
| 2 | ||
| 3 | PearBrowser separates content replication from catalogue endorsement. A relay | |
| 4 | pin approval means a relay may store and serve a drive. It does **not** by | |
| 5 | itself publish that drive into the shared Community catalogue. | |
| 6 | ||
| 7 | ## Submission lifecycle | |
| 8 | ||
| 9 | 1. A publisher opens **Apps → Submit your app**, supplies a browsable | |
| 10 | `hyper://` target, and submits it. | |
| 11 | 2. PearBrowser publishes a bounded submission receipt and sends a signed seed | |
| 12 | request for the content drive. | |
| 13 | 3. A HiveRelay running in `review` acceptance mode adds that request to its | |
| 14 | authenticated management queue. | |
| 15 | 4. An operator opens **Apps → Moderator tools**, loads the queue, and runs due | |
| 16 | diligence for one request at a time. | |
| 17 | 5. The operator previews the site, records a note, and either approves the | |
| 18 | relay pin or rejects the request with a reason. | |
| 19 | 6. Approved metadata is published into the Community Hyperbee through the | |
| 20 | separate secret-holding catalogue release process. Until this final step, | |
| 21 | the content may be replicated but is not listed for all users. | |
| 22 | ||
| 23 | A broadcast with zero initial relay acknowledgements is reported as | |
| 24 | `awaiting-relay`, not as a confirmed pending review. The client may retry the | |
| 25 | signed seed request, but an operator must see it in a relay queue before any | |
| 26 | decision is possible. | |
| 1 | # Community catalogue submission and review | |
| 2 | ||
| 3 | PearBrowser separates four concerns that must not be collapsed into one button: | |
| 4 | ||
| 5 | 1. an author releases and distributes an app; | |
| 6 | 2. the author submits a bounded catalogue receipt; | |
| 7 | 3. an operator reviews and approves that receipt; | |
| 8 | 4. a secret-holding publisher writes approved metadata to the Community catalogue. | |
| 9 | ||
| 10 | A relay approval only allows the receipt drive to be replicated. It does not | |
| 11 | endorse an app, pin a native release, or publish the shared catalogue entry. | |
| 12 | ||
| 13 | ## Pear v3 native apps | |
| 14 | ||
| 15 | Pear v3 applications are released through Pear's deployment pipeline, not by | |
| 16 | uploading executable bytes to PearBrowser or HiveRelay: | |
| 17 | ||
| 18 | 1. mint and seed the release lines with `pear touch` / `pear seed`; | |
| 19 | 2. set the package `upgrade` identity; | |
| 20 | 3. build platform artifacts and assemble the deployment with `pear build`; | |
| 21 | 4. dry-run and apply `pear stage`; | |
| 22 | 5. move the staged version to a provision line and, for production, gate it | |
| 23 | through multisig; | |
| 24 | 6. keep the stable root production link seeded. | |
| 25 | ||
| 26 | The catalogue form accepts only a canonical root | |
| 27 | `pear://<52-character-key>` identity. Versioned stage links, paths, query | |
| 28 | strings, and fragments are rejected. Authors must state the package version, | |
| 29 | the exact installed product name, and every OS/architecture target included in | |
| 30 | the 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 | ||
| 45 | PearBrowser never runs a submitted link during review. When a user later | |
| 46 | chooses **Install app**, the existing `pear-install` boundary verifies the | |
| 47 | requested release identity, exact product name, platform target, one GUI | |
| 48 | artifact, and normal OS destination before recording the installation. | |
| 49 | ||
| 50 | ## Browsable Hyper content | |
| 51 | ||
| 52 | A Hyper submission references a separately published and seeded drive. Its | |
| 53 | root `/index.html` must be available to a cold reader. The catalogue receipt | |
| 54 | does not automatically pin the target drive and does not turn Hyper content | |
| 55 | into a native application. | |
| 56 | ||
| 57 | ## Receipt lifecycle | |
| 58 | ||
| 59 | 1. The author selects **Pear v3 app** or **Hyper site**, enters bounded | |
| 60 | catalogue metadata, and optionally uploads a small image icon. | |
| 61 | 2. PearBrowser validates the target and publishes one receipt drive containing | |
| 62 | `/manifest.json`. | |
| 63 | 3. A signed HiveRelay seed request places that receipt drive in a relay's | |
| 64 | authenticated `review` queue. | |
| 65 | 4. The operator loads the queue and fetches the receipt. Hyper reviews also | |
| 66 | fetch the separately declared target drive and inspect `/index.html`. | |
| 67 | 5. The operator records a note and either approves receipt replication or | |
| 68 | rejects the request with a reason. | |
| 69 | 6. Approved metadata is published through the separate secret-holding | |
| 70 | Community catalogue release process and verified from a fresh reader. | |
| 71 | ||
| 72 | Using the receipt as the single queue item avoids the former double-queue bug: | |
| 73 | the old flow seeded a metadata drive and the app drive independently, while the | |
| 74 | moderator only knew the app key and therefore could not reliably retrieve the | |
| 75 | form metadata. | |
| 27 | 76 | |
| 28 | 77 | ## Automated due diligence |
| 29 | 78 | |
| 30 | The browser fetches evidence directly from the queued content drive and checks: | |
| 31 | ||
| 32 | - canonical 64-hex content key and visible publisher public key; | |
| 33 | - replicated Hyperdrive version and a bounded `/index.html` entrypoint; | |
| 34 | - a parseable `/manifest.json` with a non-empty app name; | |
| 35 | - manifest target consistency with the queued drive; | |
| 36 | - rejection of native, `pear://`, `file://`, package, and executable targets; | |
| 37 | - duplicate drive entries in the catalogues currently loaded by the browser; | |
| 38 | - external HTTP origins and high-signal page behavior such as dynamic code, | |
| 39 | iframes, forms, or service-worker registration; | |
| 40 | - queue age and other provenance metadata exposed by the relay. | |
| 41 | ||
| 42 | Missing content, a missing publisher key, an invalid/missing manifest, key | |
| 43 | mismatch, or an executable delivery declaration is a blocker. Duplicates, | |
| 44 | behavioral signals, mutable-drive updates, and the fact that a signing key does | |
| 45 | not prove a real-world identity are warnings. The page scan is bounded and does | |
| 46 | not inspect linked scripts. Automated checks never declare an app safe: every | |
| 47 | eligible approval still requires the operator to open the preview and | |
| 48 | explicitly acknowledge the warnings. | |
| 79 | All reviews verify a canonical receipt key, relay-visible publisher key, | |
| 80 | bounded receipt manifest, supported receipt version, duplicate target state, | |
| 81 | and queue freshness. | |
| 82 | ||
| 83 | Hyper reviews additionally verify the declared target key, target replication | |
| 84 | version, bounded root `/index.html`, and high-signal page behavior. Pear v3 | |
| 85 | reviews validate the canonical production identity, package version, exact | |
| 86 | product name, supported target list, and publisher release attestation. Native | |
| 87 | package availability and provenance remain explicit human-review items because | |
| 88 | catalogue review must not install or execute third-party code. | |
| 89 | ||
| 90 | An icon is presentation metadata only. Uploads are capped to the shared | |
| 91 | catalogue limit and restricted to PNG, JPEG, WebP, GIF, or passive SVG bytes. | |
| 92 | They never affect trust or launch decisions. | |
| 49 | 93 | |
| 50 | 94 | ## Decision records |
| 51 | 95 | |
| 96 | Approval fails closed when evidence changes, any blocker remains, the review | |
| 97 | queue is stale or not in `review` mode, the human acknowledgement is missing, | |
| 98 | or no reviewer note is recorded. Evidence binds approval to both the receipt | |
| 99 | drive version and, for Hyper submissions, the target drive version. | |
| 100 | ||
| 52 | 101 | Approvals and rejections are written to a bounded local audit trail containing |
| 53 | the app key, publisher key, action, reviewer note or rejection reason, decision | |
| 54 | time, and automated-check counts. API credentials are never copied into audit | |
| 55 | records. | |
| 56 | ||
| 57 | Rejection requires a reason. Approval fails closed if evidence has blockers or | |
| 58 | the human-review acknowledgement or reviewer note is missing. Decisions also | |
| 59 | require a queue refreshed within the last five minutes from a relay reporting | |
| 60 | `review` mode. Approval is bound to the exact Hyperdrive version shown in the | |
| 61 | review report; if the content changes before the decision, the reviewer must | |
| 62 | fetch and inspect new evidence. File-size caps are checked from authenticated | |
| 63 | Hyperdrive metadata before content is downloaded. The relay management API | |
| 64 | receives only the app key because its current protocol does not carry reviewer | |
| 65 | notes; the detailed record stays in PearBrowser until a signed shared | |
| 66 | moderation log is introduced. | |
| 102 | the receipt key, publisher key, action, note or rejection reason, decision | |
| 103 | time, and check counts. Operator credentials are never copied into audit data. | |
| 67 | 104 | |
| 68 | 105 | ## Operator checklist |
| 69 | 106 | |
| 70 | 107 | - 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. |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | # Install Native Packages |
| 2 | 2 | |
| 3 | Current release: `v0.7.1`. | |
| 3 | Current release: `v0.8.0`. | |
| 4 | 4 | |
| 5 | 5 | These are package-proof desktop builds. Linux uses checksums only. macOS is |
| 6 | 6 | ad-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. |
| 12 | 12 | ## Choose A Package |
| 13 | 13 | |
| 14 | 14 | Download 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). | |
| 16 | 16 | |
| 17 | 17 | | Machine | Recommended package | Checksum sidecar | |
| 18 | 18 | | --- | --- | --- | |
| 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) | | |
| 23 | 23 | |
| 24 | 24 | The Linux release contains exactly one product AppImage. Packaging now rejects |
| 25 | 25 | ambiguous AppImages and excludes build tools such as AppImageTool. |
| … | … | @@ -31,26 +31,26 @@ native release workflow. |
| 31 | 31 | From a source checkout, ask the resolver for the current machine: |
| 32 | 32 | |
| 33 | 33 | ```sh |
| 34 | npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop | |
| 34 | npm run resolve:native-release -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop | |
| 35 | 35 | ``` |
| 36 | 36 | |
| 37 | 37 | Or specify a target: |
| 38 | 38 | |
| 39 | 39 | ```sh |
| 40 | npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --platform macos --arch x64 | |
| 40 | npm run resolve:native-release -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop --platform macos --arch x64 | |
| 41 | 41 | ``` |
| 42 | 42 | |
| 43 | 43 | Release operators can verify every recommended package download and checksum |
| 44 | 44 | sidecar in one pass: |
| 45 | 45 | |
| 46 | 46 | ```sh |
| 47 | npm run verify:native-downloads -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --all | |
| 47 | npm run verify:native-downloads -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop --all | |
| 48 | 48 | ``` |
| 49 | 49 | |
| 50 | 50 | Release operators can regenerate this guide from the same resolver rules: |
| 51 | 51 | |
| 52 | 52 | ```sh |
| 53 | npm run -s generate:native-install-guide -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop | |
| 53 | npm run -s generate:native-install-guide -- --tag v0.8.0 --repo bigdestiny2/pearbrowser-desktop | |
| 54 | 54 | ``` |
| 55 | 55 | |
| 56 | 56 | ## Verify The Download |
| … | … | @@ -58,7 +58,7 @@ npm run -s generate:native-install-guide -- --tag v0.7.1 --repo bigdestiny2/pear |
| 58 | 58 | macOS and Linux: |
| 59 | 59 | |
| 60 | 60 | ```sh |
| 61 | shasum -a 256 -c PearBrowser-0.7.1-macos-arm64.app.zip.sha256 | |
| 61 | shasum -a 256 -c PearBrowser-0.8.0-macos-arm64.app.zip.sha256 | |
| 62 | 62 | ``` |
| 63 | 63 | |
| 64 | 64 | Use 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`. |
| 66 | 66 | Windows PowerShell: |
| 67 | 67 | |
| 68 | 68 | ```powershell |
| 69 | $package = "PearBrowser-0.7.1-windows-x64.msix" | |
| 69 | $package = "PearBrowser-0.8.0-windows-x64.msix" | |
| 70 | 70 | $expected = (Get-Content "$($package).sha256").Split(" ")[0].ToLowerInvariant() |
| 71 | 71 | $actual = (Get-FileHash $package -Algorithm SHA256).Hash.ToLowerInvariant() |
| 72 | 72 | if ($actual -ne $expected) { throw "SHA-256 mismatch for $package" } |
| … | … | @@ -88,7 +88,7 @@ macOS: |
| 88 | 88 | |
| 89 | 89 | Windows: |
| 90 | 90 | |
| 91 | 1. Install `PearBrowser-0.7.1-windows-x64.msix`. | |
| 91 | 1. Install `PearBrowser-0.8.0-windows-x64.msix`. | |
| 92 | 92 | 2. For package-proof builds, Windows SmartScreen may warn because the installer |
| 93 | 93 | is not yet Authenticode-signed. Continue only if you intentionally trust this |
| 94 | 94 | package and its checksum. |
| … | … | @@ -96,8 +96,8 @@ Windows: |
| 96 | 96 | Linux: |
| 97 | 97 | |
| 98 | 98 | ```sh |
| 99 | chmod +x PearBrowser-0.7.1-linux-x64.AppImage | |
| 100 | ./PearBrowser-0.7.1-linux-x64.AppImage | |
| 99 | chmod +x PearBrowser-0.8.0-linux-x64.AppImage | |
| 100 | ./PearBrowser-0.8.0-linux-x64.AppImage | |
| 101 | 101 | ``` |
| 102 | 102 | |
| 103 | 103 | ## Legacy Pear records |
| … | … | @@ -0,0 +1,39 @@ |
| 1 | # PearBrowser Desktop v0.8.0 | |
| 2 | ||
| 3 | PearBrowser v0.8.0 makes Pear v3 delivery a first-class, explicit catalogue | |
| 4 | contract. Native apps are installed as verified OS packages through a | |
| 5 | host-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 | ||
| 21 | The release also includes the embedded Pear v3 host migration, private-search | |
| 22 | home, reconnect-safe renderer/backend RPC, Content Shield, Pear Plugins, local | |
| 23 | Ask Browser/QVAC, federated search, naming, Nostr, and P2P publishing work that | |
| 24 | landed after v0.7.1. | |
| 25 | ||
| 26 | ## Trust and distribution note | |
| 27 | ||
| 28 | The GitHub native workflow currently produces package-proof assets with | |
| 29 | SHA-256 sidecars. macOS Developer ID notarization and Windows public-trust | |
| 30 | signing remain unavailable until production credentials are configured. The | |
| 31 | retained PearBrowser upgrade identity remains a migration record until the | |
| 32 | human-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. |
| … | … | @@ -1,12 +1,12 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pearbrowser-desktop", |
| 3 | "version": "0.7.1", | |
| 3 | "version": "0.8.0", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "pearbrowser-desktop", |
| 9 | "version": "0.7.1", | |
| 9 | "version": "0.8.0", | |
| 10 | 10 | "hasInstallScript": true, |
| 11 | 11 | "license": "Apache-2.0 AND MIT", |
| 12 | 12 | "dependencies": { |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pearbrowser-desktop", |
| 3 | 3 | "productName": "PearBrowser", |
| 4 | "version": "0.7.1", | |
| 4 | "version": "0.8.0", | |
| 5 | 5 | "description": "Local-first P2P browser, catalogue, and publishing platform with an embedded Pear OTA runtime.", |
| 6 | 6 | "license": "Apache-2.0 AND MIT", |
| 7 | 7 | "type": "module", |
| … | … | @@ -14,8 +14,8 @@ |
| 14 | 14 | // The manifest is rich (id, pearLink, homepage, source, license, platforms, …); |
| 15 | 15 | // seed rows are validated against the strict APPS_SCHEMA |
| 16 | 16 | // (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. | |
| 19 | 19 | |
| 20 | 20 | import { readFileSync, writeFileSync } from 'node:fs' |
| 21 | 21 | import { fileURLToPath } from 'node:url' |
| … | … | @@ -54,7 +54,13 @@ function toSeedRow (a) { |
| 54 | 54 | if (a.driveKey && /^[0-9a-f]{64}$/i.test(a.driveKey)) row.driveKey = a.driveKey |
| 55 | 55 | if (a.link) row.link = a.link |
| 56 | 56 | 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 | } | |
| 58 | 64 | // Inline icon (data: URI) for apps without a fetchable drive icon — rendered |
| 59 | 65 | // directly by AppIcon, so every catalogue app shows a real icon, not a glyph. |
| 60 | 66 | if (a.iconData) row.iconData = a.iconData |
| … | … | @@ -72,7 +78,7 @@ function toSeedRow (a) { |
| 72 | 78 | const rows = cat.apps.map(toSeedRow) |
| 73 | 79 | for (const [i, r] of rows.entries()) { |
| 74 | 80 | 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`) | |
| 76 | 82 | } |
| 77 | 83 | |
| 78 | 84 | const header = `/** |
| … | … | @@ -59,6 +59,8 @@ export function normalizeManifest (manifest, now = Date.now()) { |
| 59 | 59 | if (typeof clean.sourceUrl === 'string' && clean.sourceUrl) entry.sourceUrl = clean.sourceUrl |
| 60 | 60 | if (typeof clean.license === 'string' && clean.license) entry.license = clean.license |
| 61 | 61 | 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 | |
| 62 | 64 | // Inline icon (data: URI) — carried so apps WITHOUT a fetchable drive icon |
| 63 | 65 | // (pear://-only apps, or drives lacking /icon.*) still render a real icon. |
| 64 | 66 | if (typeof clean.iconData === 'string' && clean.iconData) entry.iconData = clean.iconData |
| … | … | @@ -126,8 +126,25 @@ async function main () { |
| 126 | 126 | if (pearbrowser.version !== args.expectBrowserVersion) { |
| 127 | 127 | fail(`PearBrowser version mismatch: expected ${args.expectBrowserVersion}, got ${pearbrowser.version || '(missing)'}`) |
| 128 | 128 | } |
| 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') | |
| 131 | 148 | } |
| 132 | 149 | if (pearbrowser.homepage !== args.expectBrowserHomepage) { |
| 133 | 150 | fail(`PearBrowser homepage mismatch: expected ${args.expectBrowserHomepage}, got ${pearbrowser.homepage || '(missing)'}`) |
| … | … | @@ -157,7 +174,7 @@ async function main () { |
| 157 | 174 | |
| 158 | 175 | console.log(' → signed meta signature:', signedMeta.value.signature.slice(0, 16) + '…') |
| 159 | 176 | 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) | |
| 161 | 178 | if (peercord) console.log(' → Peercord:', peercord.type, '·', peercord.sourceUrl, '·', peercord.license) |
| 162 | 179 | console.log(' → expected apps:', args.expectApps.length ? args.expectApps.join(', ') : '(none)') |
| 163 | 180 | console.log() |
| … | … | @@ -2364,7 +2364,74 @@ p { color: var(--pear-color-muted); margin-bottom: 12px; } |
| 2364 | 2364 | .search-fed-toggle input { cursor: pointer; } |
| 2365 | 2365 | .search-fed-toggle .fed-status { color: var(--pear-color-link); font-style: italic; } |
| 2366 | 2366 | |
| 2367 | /* Community catalogue moderation queue. */ | |
| 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 | } | |
| 2368 | 2435 | .mod-process { |
| 2369 | 2436 | display: flex; |
| 2370 | 2437 | align-items: center; |
| … | … | @@ -2497,6 +2564,7 @@ p { color: var(--pear-color-muted); margin-bottom: 12px; } |
| 2497 | 2564 | .mod-audit-row code { color: var(--pear-color-text); } |
| 2498 | 2565 | .mod-audit-row time { white-space: nowrap; } |
| 2499 | 2566 | @media (max-width: 760px) { |
| 2567 | .community-targets { grid-template-columns: 1fr; } | |
| 2500 | 2568 | .mod-audit-row { grid-template-columns: auto 1fr; } |
| 2501 | 2569 | .mod-audit-row span:nth-child(3), .mod-audit-row time { grid-column: 1 / -1; } |
| 2502 | 2570 | } |
| … | … | @@ -15,7 +15,20 @@ const SAMPLE = { |
| 15 | 15 | version: 1, |
| 16 | 16 | apps: [ |
| 17 | 17 | { 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 | } | |
| 19 | 32 | ] |
| 20 | 33 | } |
| 21 | 34 | |
| … | … | @@ -23,7 +36,7 @@ test('normalizeManifest cleans input and applies defaults', () => { |
| 23 | 36 | const n = normalizeManifest(SAMPLE, 1700000000000) |
| 24 | 37 | assert.equal(n.name, 'Pear Picks') // trimmed |
| 25 | 38 | assert.equal(n.version, 1) |
| 26 | assert.equal(n.apps.length, 2) | |
| 39 | assert.equal(n.apps.length, 3) | |
| 27 | 40 | assert.equal(n.apps[0].publishedAt, 1700000000000) // injected default now |
| 28 | 41 | assert.deepEqual(n.apps[0].categories, ['chat']) |
| 29 | 42 | 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 () => { |
| 74 | 87 | assert.equal(data.name, 'Pear Picks') |
| 75 | 88 | assert.equal(data.version, 1) |
| 76 | 89 | 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) | |
| 79 | 92 | |
| 80 | 93 | const byId = Object.fromEntries(data.apps.map((a) => [a.id, a])) |
| 81 | 94 | assert.equal(byId.keet.name, 'Keet') |
| … | … | @@ -86,6 +99,14 @@ test('round-trips through a real Hyperbee using the loader query', async () => { |
| 86 | 99 | assert.equal(byId.keet.homepage, 'https://keet.io') |
| 87 | 100 | assert.equal(byId.keet.license, 'Apache-2.0') |
| 88 | 101 | 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) | |
| 89 | 110 | |
| 90 | 111 | await store.close() |
| 91 | 112 | } finally { |
| … | … | @@ -0,0 +1,63 @@ |
| 1 | import test from 'node:test' | |
| 2 | import assert from 'node:assert/strict' | |
| 3 | import { readFileSync } from 'node:fs' | |
| 4 | ||
| 5 | const backend = readFileSync(new URL('../backend/index.js', import.meta.url), 'utf8') | |
| 6 | const ui = readFileSync(new URL('../ui/shell.js', import.meta.url), 'utf8') | |
| 7 | const constants = readFileSync(new URL('../backend/constants.js', import.meta.url), 'utf8') | |
| 8 | const seedGenerator = readFileSync(new URL('../scripts/gen-catalogue-seed.mjs', import.meta.url), 'utf8') | |
| 9 | ||
| 10 | function 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 | ||
| 18 | test('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 | ||
| 27 | test('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 | ||
| 36 | test('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 | ||
| 49 | test('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 | ||
| 57 | test('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 | }) |
| … | … | @@ -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`. | |
| 4 | 1 | import test from 'node:test' |
| 5 | 2 | import assert from 'node:assert/strict' |
| 6 | 3 | import communitySubmit from '../backend/community-submit.cjs' |
| 7 | 4 | |
| 8 | 5 | const { |
| 6 | MAX_ICON_DATA_CHARS, | |
| 7 | MAX_INDEX_BYTES, | |
| 9 | 8 | slugify, |
| 9 | normalizePearInstallLink, | |
| 10 | normalizeIconData, | |
| 10 | 11 | deriveKeyAndLink, |
| 11 | 12 | buildSubmissionManifest, |
| 12 | 13 | manageRequest, |
| 13 | 14 | communityBeeEntry, |
| 14 | 15 | buildReviewReport, |
| 15 | reviewEvidenceMatches, | |
| 16 | MAX_INDEX_BYTES | |
| 16 | reviewEvidenceMatches | |
| 17 | 17 | } = communitySubmit |
| 18 | 18 | |
| 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. | |
| 22 | const HEX = 'f5fb7500bccd60a976d2b1d24246108f4444a210b9ca591533114dffc089934d' | |
| 23 | // Minimal stand-in: hex passes straight through; a sentinel z32 maps to HEX. | |
| 24 | const Z32 = 'yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy' | |
| 19 | const RECEIPT = 'f5fb7500bccd60a976d2b1d24246108f4444a210b9ca591533114dffc089934d' | |
| 20 | const TARGET = 'a'.repeat(64) | |
| 21 | const Z32 = 'y'.repeat(52) | |
| 22 | const PEAR_KEY = 'a'.repeat(52) | |
| 23 | const PEAR_LINK = `pear://${PEAR_KEY}` | |
| 24 | const PUBLISHER = 'b'.repeat(64) | |
| 25 | const PNG_ICON = `data:image/png;base64,${Buffer.from('89504e470d0a1a0a', 'hex').toString('base64')}` | |
| 26 | ||
| 25 | 27 | const fakeNormalize = (raw) => { |
| 26 | 28 | if (/^[0-9a-f]{64}$/i.test(raw)) return raw.toLowerCase() |
| 27 | if (raw === Z32) return HEX | |
| 29 | if (raw === Z32) return TARGET | |
| 28 | 30 | return raw |
| 29 | 31 | } |
| 30 | 32 | |
| 33 | function 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 | ||
| 31 | 49 | test('slugify produces stable catalogue-safe ids', () => { |
| 32 | 50 | assert.equal(slugify('Cool App!!'), 'cool-app') |
| 33 | 51 | assert.equal(slugify(' Spaces & Symbols '), 'spaces-symbols') |
| 34 | 52 | assert.equal(slugify(''), 'app') |
| 35 | assert.equal(slugify(null), 'app') | |
| 36 | 53 | assert.equal(slugify('A'.repeat(200)).length, 64) |
| 37 | 54 | }) |
| 38 | 55 | |
| 39 | test('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 | ||
| 47 | test('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 | ||
| 53 | test('deriveKeyAndLink: remote executable app link is rejected', () => { | |
| 54 | const r = deriveKeyAndLink('pear://' + Z32, fakeNormalize) | |
| 55 | assert.match(r.error, /not accepted/i) | |
| 56 | }) | |
| 57 | ||
| 58 | test('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) | |
| 56 | test('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) | |
| 61 | 62 | }) |
| 62 | 63 | |
| 63 | test('deriveKeyAndLink: rejects junk + empty', () => { | |
| 64 | test('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) | |
| 64 | 72 | assert.ok(deriveKeyAndLink('', fakeNormalize).error) |
| 65 | assert.ok(deriveKeyAndLink('not-a-key', fakeNormalize).error) | |
| 66 | 73 | }) |
| 67 | 74 | |
| 68 | test('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/>') | |
| 75 | test('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) | |
| 89 | 84 | }) |
| 90 | 85 | |
| 91 | test('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 | }) | |
| 86 | test('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 }) | |
| 98 | 96 | |
| 99 | test('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) | |
| 102 | 106 | }) |
| 103 | 107 | |
| 104 | test('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) | |
| 108 | test('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 | }) | |
| 111 | 130 | }) |
| 112 | 131 | |
| 113 | test('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') | |
| 132 | test('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) | |
| 119 | 139 | }) |
| 120 | 140 | |
| 121 | test('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') | |
| 141 | test('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) | |
| 128 | 144 | }) |
| 129 | 145 | |
| 130 | test('manageRequest: guards missing url + bad appKey + bad action', () => { | |
| 146 | test('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 }) | |
| 131 | 154 | assert.ok(manageRequest('pending', {}).error) |
| 132 | assert.ok(manageRequest('approve', { baseUrl: 'ftp://x', appKey: HEX }).error) | |
| 133 | 155 | assert.ok(manageRequest('approve', { baseUrl: 'http://x', appKey: 'short' }).error) |
| 134 | assert.ok(manageRequest('bogus', { baseUrl: 'http://x' }).error) | |
| 135 | 156 | }) |
| 136 | 157 | |
| 137 | test('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') | |
| 158 | test('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}/`) | |
| 148 | 171 | }) |
| 149 | 172 | |
| 150 | test('buildReviewReport: eligible web content still requires human acknowledgement', () => { | |
| 173 | test('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 | |
| 151 | 180 | 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, | |
| 155 | 187 | indexText: '<!doctype html><html><body>Hello</body></html>', |
| 156 | 188 | indexBytes: 47, |
| 157 | driveVersion: 4, | |
| 158 | duplicates: [] | |
| 189 | duplicates: [], | |
| 190 | normalizeKey: fakeNormalize | |
| 159 | 191 | }, 2000) |
| 160 | 192 | |
| 161 | 193 | 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) | |
| 170 | 199 | assert.ok(report.checks.some((check) => check.id === 'mutable-content' && check.status === 'warning')) |
| 171 | assert.ok(report.checks.some((check) => check.id === 'human-preview' && check.status === 'warning')) | |
| 172 | 200 | }) |
| 173 | 201 | |
| 174 | test('buildReviewReport: missing manifest or entrypoint blocks approval', () => { | |
| 202 | test('Pear v3 review validates metadata without pretending to execute or pin the package', () => { | |
| 203 | const manifest = buildSubmissionManifest(nativeInput(), { now: 1000 }).manifest | |
| 175 | 204 | 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) | |
| 182 | 211 | |
| 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) | |
| 187 | 220 | }) |
| 188 | 221 | |
| 189 | test('buildReviewReport: native delivery and mismatched targets are blockers', () => { | |
| 190 | const OTHER = 'a'.repeat(64) | |
| 191 | const native = buildReviewReport({ | |
| 192 | appKey: HEX, | |
| 193 | pending: { appKey: HEX, publisherPubkey: 'b'.repeat(64) }, | |
| 194 | driveVersion: 1, | |
| 195 | indexText: '<html><body>native</body></html>', | |
| 196 | manifest: { name: 'Native App', type: 'standalone', pearLink: 'pear://example' } | |
| 197 | }) | |
| 198 | assert.equal(native.approvalAllowed, false) | |
| 199 | assert.ok(native.checks.some((check) => check.id === 'delivery-boundary' && check.status === 'block')) | |
| 200 | ||
| 201 | const mismatch = buildReviewReport({ | |
| 202 | appKey: HEX, | |
| 203 | pending: { appKey: HEX, publisherPubkey: 'b'.repeat(64) }, | |
| 204 | driveVersion: 1, | |
| 205 | indexText: '<html><body>site</body></html>', | |
| 206 | manifest: { name: 'Wrong Target', link: 'hyper://' + OTHER + '/' } | |
| 207 | }) | |
| 208 | assert.equal(mismatch.approvalAllowed, false) | |
| 209 | assert.match(mismatch.checks.find((check) => check.id === 'delivery-boundary').detail, /does not match/i) | |
| 210 | ||
| 211 | const invalid = buildReviewReport({ | |
| 212 | appKey: HEX, | |
| 213 | pending: { appKey: HEX, publisherPubkey: 'b'.repeat(64) }, | |
| 214 | driveVersion: 1, | |
| 215 | indexText: '<html><body>site</body></html>', | |
| 216 | manifest: { name: 'Clearnet Target', link: 'https://example.com/app' } | |
| 222 | test('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' | |
| 217 | 228 | }) |
| 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')) | |
| 221 | 231 | |
| 222 | test('buildReviewReport: injected key normalization accepts a matching z32 manifest target', () => { | |
| 223 | const report = buildReviewReport({ | |
| 224 | appKey: HEX, | |
| 225 | pending: { appKey: HEX, publisherPubkey: 'b'.repeat(64) }, | |
| 226 | driveVersion: 1, | |
| 227 | indexText: '<html><body>site</body></html>', | |
| 228 | manifest: { name: 'Z32 Target', link: 'hyper://' + Z32 + '/' }, | |
| 229 | normalizeKey: fakeNormalize | |
| 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' | |
| 230 | 240 | }) |
| 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')) | |
| 233 | 244 | }) |
| 234 | 245 | |
| 235 | test('buildReviewReport: duplicates and external behavior are review warnings', () => { | |
| 246 | test('review surfaces duplicates, external behavior, and oversized entrypoints', () => { | |
| 247 | const manifest = buildSubmissionManifest({ name: 'External', submissionKind: 'hyper', link: TARGET }, { normalizeKey: fakeNormalize }).manifest | |
| 236 | 248 | 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, | |
| 240 | 255 | 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 }] | |
| 243 | 257 | }) |
| 244 | ||
| 245 | 258 | assert.equal(report.approvalAllowed, true) |
| 246 | assert.ok(report.checks.some((check) => check.id === 'duplicate' && check.status === 'warning')) | |
| 247 | assert.ok(report.checks.some((check) => check.id === 'page-behavior' && check.status === 'warning')) | |
| 248 | 259 | assert.deepEqual(report.evidence.externalOrigins, ['https://cdn.example']) |
| 249 | 260 | assert.deepEqual(report.evidence.behaviorSignals, ['form submission']) |
| 250 | }) | |
| 261 | assert.ok(report.checks.some((check) => check.id === 'duplicate' && check.status === 'warning')) | |
| 251 | 262 | |
| 252 | test('buildReviewReport: oversized index.html is a blocker', () => { | |
| 253 | const report = buildReviewReport({ | |
| 254 | appKey: HEX, | |
| 255 | pending: { appKey: HEX, publisherPubkey: 'b'.repeat(64) }, | |
| 256 | driveVersion: 1, | |
| 263 | const oversized = buildReviewReport({ | |
| 264 | appKey: RECEIPT, | |
| 265 | pending: { appKey: RECEIPT, publisherPubkey: PUBLISHER }, | |
| 266 | receiptDriveVersion: 1, | |
| 267 | manifest, | |
| 268 | targetDriveKey: TARGET, | |
| 269 | targetDriveVersion: 2, | |
| 257 | 270 | indexText: '<html></html>', |
| 258 | indexBytes: MAX_INDEX_BYTES + 1, | |
| 259 | manifest: { name: 'Too Large', driveKey: HEX } | |
| 271 | indexBytes: MAX_INDEX_BYTES + 1 | |
| 260 | 272 | }) |
| 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')) | |
| 263 | 275 | }) |
| 264 | 276 | |
| 265 | test('reviewEvidenceMatches binds approval to the reviewed drive version', () => { | |
| 266 | const report = { checkedAt: 1234, evidence: { driveVersion: 9 } } | |
| 267 | assert.equal(reviewEvidenceMatches({ reviewedAt: 1234, reviewedDriveVersion: 9 }, report), true) | |
| 268 | assert.equal(reviewEvidenceMatches({ reviewedAt: 1234, reviewedDriveVersion: 10 }, report), false) | |
| 269 | assert.equal(reviewEvidenceMatches({ reviewedAt: 1235, reviewedDriveVersion: 9 }, report), false) | |
| 277 | test('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) | |
| 270 | 282 | assert.equal(reviewEvidenceMatches({}, report), false) |
| 271 | assert.equal(reviewEvidenceMatches({ reviewedAt: 1234, reviewedDriveVersion: 9 }, null), false) | |
| 272 | 283 | }) |
| … | … | @@ -52,7 +52,7 @@ test('mobile Bookmarks, History, and More hub expose local data workflows and st |
| 52 | 52 | 'removeBookmark(url)', |
| 53 | 53 | 'onPress={() => onOpen(b.url)}', |
| 54 | 54 | 'No bookmarks yet', |
| 55 | 'Bookmark sites while browsing', | |
| 55 | 'Save the current site from Page actions while browsing.', | |
| 56 | 56 | ]) |
| 57 | 57 | |
| 58 | 58 | includesAll(history, [ |
| … | … | @@ -182,22 +182,27 @@ test('mobile site editor supports block editing, theme selection, preview, save, |
| 182 | 182 | ]) |
| 183 | 183 | }) |
| 184 | 184 | |
| 185 | test('mobile native Explore screens preserve safe catalog targets like React Native Explore', { skip: !hasMobileCheckout && 'mobile checkout is not present in this isolated desktop worktree' }, () => { | |
| 185 | test('mobile Explore screens preserve Pear v3 metadata without executing native links', { skip: !hasMobileCheckout && 'mobile checkout is not present in this isolated desktop worktree' }, () => { | |
| 186 | 186 | const rn = source('app/screens/ExploreScreen.tsx') |
| 187 | 187 | const ios = source('ios-native/PearBrowser/Sources/UI/Screens/ExploreScreen.swift') |
| 188 | 188 | const android = source('android-native/app/src/main/java/com/pearbrowser/app/ui/screens/ExploreScreen.kt') |
| 189 | 189 | |
| 190 | 190 | includesAll(rn, [ |
| 191 | 191 | 'normalizeEntry', |
| 192 | 'site.link', | |
| 193 | 'onVisit(site.link)', | |
| 192 | 'normalizeNativeDelivery', | |
| 193 | 'nativeDelivery?: NativeDelivery', | |
| 194 | 'catalogAction', | |
| 195 | 'onVisit(action.target)', | |
| 196 | 'Desktop only', | |
| 194 | 197 | 'filter((site): site is SiteInfo => !!site)', |
| 195 | 198 | ]) |
| 196 | 199 | |
| 197 | 200 | includesAll(ios, [ |
| 198 | 201 | 'let driveKey: String?', |
| 199 | 202 | 'let link: String?', |
| 203 | 'let nativeDelivery: NativeDelivery?', | |
| 200 | 204 | 'normalizeCatalogLink', |
| 205 | 'normalizeNativeDelivery', | |
| 201 | 206 | 'normalizeDriveKey', |
| 202 | 207 | 'driveKeyFromHyperLink', |
| 203 | 208 | 'onVisit(link)', |
| … | … | @@ -207,10 +212,12 @@ test('mobile native Explore screens preserve safe catalog targets like React Nat |
| 207 | 212 | includesAll(android, [ |
| 208 | 213 | 'val driveKey: String?', |
| 209 | 214 | 'val link: String?', |
| 215 | 'val nativeDelivery: NativeDelivery?', | |
| 210 | 216 | 'normalizeCatalogLink', |
| 217 | 'normalizeNativeDelivery', | |
| 211 | 218 | 'normalizeDriveKey', |
| 212 | 219 | 'driveKeyFromHyperLink', |
| 213 | 'val target = site.link', | |
| 220 | 'val target = if (site.desktopPackage) null else', | |
| 214 | 221 | '"pear", "file" ->', |
| 215 | 222 | ]) |
| 216 | 223 | }) |
| … | … | @@ -269,7 +269,7 @@ test('Pear stage ignore excludes local release/operator scratch files', () => { |
| 269 | 269 | assert.ok(ignored.includes('/test')) |
| 270 | 270 | }) |
| 271 | 271 | |
| 272 | test('PearBrowser catalogue row is a migration-required native record, not a remote launcher', () => { | |
| 272 | test('PearBrowser catalogue row stays migration-only until its Pear v3 production quorum is released', () => { | |
| 273 | 273 | const source = catalogSource.apps.find((app) => app.id === 'pearbrowser-desktop') |
| 274 | 274 | const seed = SEED_APPS.find((app) => app.name === 'PearBrowser Desktop') |
| 275 | 275 | const homepageKey = '03f0060a35451cfb6b68ad1dda1b8474ebb43fd9100071ccf7d67679a83ebb4f' |
| … | … | @@ -277,13 +277,15 @@ test('PearBrowser catalogue row is a migration-required native record, not a rem |
| 277 | 277 | assert.ok(source, 'catalogue source row missing') |
| 278 | 278 | assert.ok(seed, 'offline catalogue seed row missing') |
| 279 | 279 | assert.equal(source.version, pkg.version) |
| 280 | assert.equal(source.type, 'standalone') | |
| 280 | 281 | assert.equal(source.legacyMigrationId, pkg.upgrade.replace(/^pear:\/\//, '')) |
| 281 | assert.equal(source.nativeDelivery?.status, 'migration-required') | |
| 282 | assert.deepEqual(source.nativeDelivery, { status: 'migration-required' }) | |
| 282 | 283 | assert.equal(source.driveKey, homepageKey) |
| 283 | 284 | assert.equal(source.homepage, `hyper://${homepageKey}/`) |
| 284 | 285 | assert.equal(source.link, source.homepage) |
| 285 | 286 | assert.equal(seed.version, source.version) |
| 286 | 287 | assert.equal(seed.nativeDeliveryStatus, source.nativeDelivery.status) |
| 288 | assert.equal(seed.nativeInstallLink, undefined) | |
| 287 | 289 | assert.equal(seed.link, source.link) |
| 288 | 290 | assert.equal(seed.driveKey, source.driveKey) |
| 289 | 291 | assert.equal(seed.homepage, source.homepage) |
| … | … | @@ -2254,7 +2256,9 @@ test('release story smoke covers browse, catalogue, local stories, and opt-in si |
| 2254 | 2256 | |
| 2255 | 2257 | test('live catalogue verifier asserts PearBrowser release contract and Peercord provenance metadata', () => { |
| 2256 | 2258 | 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/) | |
| 2258 | 2262 | assert.match(liveCatalogVerifier, /PearBrowser homepage mismatch/) |
| 2259 | 2263 | assert.match(liveCatalogVerifier, /Peercord sourceUrl mismatch/) |
| 2260 | 2264 | assert.match(liveCatalogVerifier, /https:\/\/git\.churchofmalware\.org\/mastercodeon\/Peercord/) |
| … | … | @@ -2480,48 +2480,118 @@ function CollaborativeCatalog ({ rpc, C }) { |
| 2480 | 2480 | ` |
| 2481 | 2481 | } |
| 2482 | 2482 | |
| 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. | |
| 2487 | 2487 | function CommunitySubmit ({ rpc, C }) { |
| 2488 | const [submissionKind, setSubmissionKind] = useState('pear-v3') | |
| 2488 | 2489 | const [name, setName] = useState('') |
| 2489 | 2490 | 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) | |
| 2490 | 2495 | const [description, setDescription] = useState('') |
| 2491 | 2496 | const [author, setAuthor] = useState('') |
| 2492 | 2497 | const [categories, setCategories] = useState('') |
| 2498 | const [iconData, setIconData] = useState('') | |
| 2499 | const [iconName, setIconName] = useState('') | |
| 2493 | 2500 | const [busy, setBusy] = useState(false) |
| 2494 | 2501 | const [err, setErr] = useState('') |
| 2495 | 2502 | const [ok, setOk] = useState('') |
| 2496 | 2503 | |
| 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 | ||
| 2497 | 2545 | const submit = async () => { |
| 2498 | 2546 | setErr(''); setOk('') |
| 2499 | 2547 | 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 } | |
| 2502 | 2553 | setBusy(true) |
| 2503 | 2554 | try { |
| 2504 | 2555 | 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 | |
| 2507 | 2567 | }, 90000) |
| 2508 | 2568 | const submittedName = (res && res.manifest && res.manifest.name) || name.trim() |
| 2509 | 2569 | 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.` | |
| 2511 | 2571 | : 'The request was broadcast, but no relay acknowledged it within the initial window; the client will retry and it is not yet confirmed in a review queue.' |
| 2512 | 2572 | const receiptStatus = res && res.receiptWarning ? ` ${res.receiptWarning}` : '' |
| 2513 | 2573 | 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('') | |
| 2515 | 2576 | } catch (e) { setErr((e && e.message) || String(e)) } finally { setBusy(false) } |
| 2516 | 2577 | } |
| 2517 | 2578 | |
| 2518 | 2579 | return html` |
| 2519 | 2580 | <div className="community-submit"> |
| 2520 | 2581 | <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> | |
| 2522 | 2583 | <div className="settings-card"> |
| 2523 | 2584 | ${err && html`<div className="apps-error">${err}</div>`} |
| 2524 | 2585 | ${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> | |
| 2525 | 2595 | <div className="settings-row"> |
| 2526 | 2596 | <div className="profile-field"> |
| 2527 | 2597 | <div className="settings-label">App name *</div> |
| … | … | @@ -2530,10 +2600,33 @@ function CommunitySubmit ({ rpc, C }) { |
| 2530 | 2600 | </div> |
| 2531 | 2601 | <div className="settings-row"> |
| 2532 | 2602 | <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)} /> | |
| 2535 | 2605 | </div> |
| 2536 | 2606 | </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 | `} | |
| 2537 | 2630 | <div className="settings-row"> |
| 2538 | 2631 | <div className="profile-field"> |
| 2539 | 2632 | <div className="settings-label">Description</div> |
| … | … | @@ -2550,8 +2643,28 @@ function CommunitySubmit ({ rpc, C }) { |
| 2550 | 2643 | <input className="profile-input" placeholder="tools, social" value=${categories} onInput=${(e) => setCategories(e.target.value)} /> |
| 2551 | 2644 | </div> |
| 2552 | 2645 | </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 | `} | |
| 2553 | 2666 | <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> | |
| 2555 | 2668 | </div> |
| 2556 | 2669 | </div> |
| 2557 | 2670 | </div> |
| … | … | @@ -2634,7 +2747,8 @@ function ModeratorPanel ({ rpc, C, onPreview }) { |
| 2634 | 2747 | appKey, |
| 2635 | 2748 | acknowledged: acknowledged[appKey] === true, |
| 2636 | 2749 | 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, | |
| 2638 | 2752 | note, |
| 2639 | 2753 | reason |
| 2640 | 2754 | }, 60000) |
| … | … | @@ -2645,8 +2759,8 @@ function ModeratorPanel ({ rpc, C, onPreview }) { |
| 2645 | 2759 | ? res.auditWarning |
| 2646 | 2760 | : (approve |
| 2647 | 2761 | ? (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.') | |
| 2650 | 2764 | : 'Rejected with an audit reason.')) |
| 2651 | 2765 | } catch (e) { setErr(e.message) } finally { setBusy(null) } |
| 2652 | 2766 | } |
| … | … | @@ -2667,11 +2781,11 @@ function ModeratorPanel ({ rpc, C, onPreview }) { |
| 2667 | 2781 | <div className="settings-card"> |
| 2668 | 2782 | ${err && html`<div className="apps-error">${err}</div>`} |
| 2669 | 2783 | ${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> | |
| 2671 | 2785 | <div className="mod-process"> |
| 2672 | 2786 | <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> | |
| 2675 | 2789 | <span><strong>4</strong> Publish catalogue</span> |
| 2676 | 2790 | </div> |
| 2677 | 2791 | <div className="settings-row"> |
| … | … | @@ -2714,7 +2828,7 @@ function ModeratorPanel ({ rpc, C, onPreview }) { |
| 2714 | 2828 | </div> |
| 2715 | 2829 | <div className="mod-review-actions"> |
| 2716 | 2830 | <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>`} | |
| 2718 | 2832 | </div> |
| 2719 | 2833 | ${report && html` |
| 2720 | 2834 | <div className="mod-summary"> |
| … | … | @@ -2727,6 +2841,7 @@ function ModeratorPanel ({ rpc, C, onPreview }) { |
| 2727 | 2841 | <strong>${report.manifest.name}</strong>${report.manifest.version ? ` · v${report.manifest.version}` : ''}${report.manifest.author ? ` · ${report.manifest.author}` : ''} |
| 2728 | 2842 | ${report.manifest.description && html`<div>${report.manifest.description}</div>`} |
| 2729 | 2843 | ${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>`} | |
| 2730 | 2845 | </div> |
| 2731 | 2846 | `} |
| 2732 | 2847 | <div className="mod-checks"> |
| … | … | @@ -2739,7 +2854,9 @@ function ModeratorPanel ({ rpc, C, onPreview }) { |
| 2739 | 2854 | </div> |
| 2740 | 2855 | <label className="mod-ack"> |
| 2741 | 2856 | <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.'} | |
| 2743 | 2860 | </label> |
| 2744 | 2861 | `} |
| 2745 | 2862 | <div className="profile-field"> |
| … | … | @@ -2751,7 +2868,7 @@ function ModeratorPanel ({ rpc, C, onPreview }) { |
| 2751 | 2868 | <input className="profile-input" placeholder="Required only when rejecting" value=${reasons[p.appKey] || ''} onInput=${(e) => setReasons((current) => ({ ...current, [p.appKey]: e.target.value }))} /> |
| 2752 | 2869 | </div> |
| 2753 | 2870 | <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> | |
| 2755 | 2872 | <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> |
| 2756 | 2873 | </div> |
| 2757 | 2874 | </div> |