| … | … | @@ -0,0 +1,73 @@ |
| 1 | # Bounded app release storage | |
| 2 | ||
| 3 | HiveRelay app publishing now uses three linked controls: | |
| 4 | ||
| 5 | 1. The live Hyperdrive tree is mirrored exactly. Byte-identical files are not | |
| 6 | rewritten, changed files use Hyperblobs block deduplication, and removed | |
| 7 | build paths are deleted. | |
| 8 | 2. Every release carries a domain-separated Ed25519 statement in | |
| 9 | `manifest.json` at `hiverelay.release`. The statement binds the publisher, | |
| 10 | app id, version, release sequence, drive key, content-tree hash, per-drive | |
| 11 | storage budget, and rollback drive keys. | |
| 12 | 3. Before a publish would exceed its per-drive budget, the publisher creates a | |
| 13 | new uniquely-namespaced Hyperdrive. It signs the old-key to new-key | |
| 14 | transition and writes the same statement to the old drive at | |
| 15 | `/.hiverelay/rotation.json`. | |
| 16 | ||
| 17 | Relays verify the signed statement against the pinned manifest, recompute its | |
| 18 | content-tree hash from that exact pinned Hyperdrive version, and bind it to the | |
| 19 | publisher from the seed request and the seed storage bound. A relay only | |
| 20 | accepts a key rotation when it names the currently served predecessor. Release | |
| 21 | authority is durably persisted before reclamation can begin and survives relay | |
| 22 | restart. Reclamation is scoped to the exact app id and publisher, retains the | |
| 23 | signed rollback key set, and sends only older drives from that release chain | |
| 24 | through the existing unseed, tombstone, and purge path. Archive, custody, and | |
| 25 | unexpired paid-lease entries remain non-purgable. | |
| 26 | ||
| 27 | ## Publisher usage | |
| 28 | ||
| 29 | ```sh | |
| 30 | node scripts/publish-app.js ./dist \ | |
| 31 | --id my-app \ | |
| 32 | --name "My App" \ | |
| 33 | --version 2.4.0 \ | |
| 34 | --storage-budget 1GiB \ | |
| 35 | --rollback-window 3 | |
| 36 | ``` | |
| 37 | ||
| 38 | `--name` or `--id` is required because a signed chain needs a stable app | |
| 39 | identity. The defaults are a 1 GiB budget per drive and three retained | |
| 40 | releases. The rollback window accepts 1 through 32 releases. | |
| 41 | ||
| 42 | Publisher state is stored under the selected `--storage` directory: | |
| 43 | ||
| 44 | - `release-state.json` contains the mode-0600 Ed25519 seed, monotonic sequence, | |
| 45 | generation, active drive key, and bounded release history. | |
| 46 | - `app-drives.json` contains the active app-id to drive-key lookup. | |
| 47 | ||
| 48 | Back up the publisher storage. The signing seed is the authority for the | |
| 49 | release chain, while the Corestore contains the writable Hyperdrive keys. | |
| 50 | ||
| 51 | When no relay URLs are supplied and `--no-stay` is set, the script performs an | |
| 52 | offline publish without binding a DHT socket. With an operator API key it uses | |
| 53 | `/seed`; without one it sends the existing replay-hardened publisher-signed | |
| 54 | request to `/api/v1/seed`. | |
| 55 | ||
| 56 | ## Storage semantics | |
| 57 | ||
| 58 | The publisher reserves 64 KiB inside each budget for the signed predecessor | |
| 59 | pointer and uses a conservative metadata estimate before writing. A single | |
| 60 | release that cannot fit in a fresh drive plus that reserve is rejected with the | |
| 61 | estimated required size. | |
| 62 | ||
| 63 | The rollback window is measured in signed releases. Multiple releases can live | |
| 64 | in the same drive, so the retained drive-key list is deduplicated. On relays, | |
| 65 | signed release sequence is authoritative for supersession; legacy unsigned | |
| 66 | entries continue to use semantic version ordering until that publisher adopts | |
| 67 | a signed chain. Once adopted, an older unsigned manifest cannot regain release | |
| 68 | authority by claiming a larger semantic version. | |
| 69 | ||
| 70 | Blind releases still receive publisher-side delta syncing, budgeting, and a | |
| 71 | signed encrypted rotation pointer. Relays preserve their existing blind-drive | |
| 72 | non-inspection contract, so automatic manifest-based rollback reclamation is | |
| 73 | limited to non-blind releases. |
| … | … | @@ -62,7 +62,9 @@ The practical path is: |
| 62 | 62 | 1. Build a Hyperdrive. |
| 63 | 63 | 2. Include a root `manifest.json`. |
| 64 | 64 | 3. Publish with `HiveRelayClient.publish(...)` or |
| 65 | [`../scripts/publish-app.js`](../scripts/publish-app.js). | |
| 65 | [`../scripts/publish-app.js`](../scripts/publish-app.js). App publishers can | |
| 66 | bound append-only release history with the signed lifecycle described in | |
| 67 | [`APP-RELEASE-LIFECYCLE.md`](APP-RELEASE-LIFECYCLE.md). | |
| 66 | 68 | 4. Seed the resulting drive on one or more relays. |
| 67 | 69 | 5. Verify that the drive is reachable through both P2P and relay HTTP. |
| 68 | 70 |
| … | … | @@ -9,7 +9,7 @@ |
| 9 | 9 | * - Disk persistence (auto-saves on every mutation) |
| 10 | 10 | * - Startup recovery (loads from disk, reseeds drives) |
| 11 | 11 | * - Catalog generation (for HTTP /catalog.json and P2P broadcast) |
| 12 | * - Version deduplication (only keep latest version per appId) | |
| 12 | * - Release deduplication (signed sequence, with legacy version fallback) | |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | 15 | import { readFile, writeFile, rename, unlink } from 'fs/promises' |
| … | … | @@ -19,6 +19,7 @@ import Hyperbee from 'hyperbee' |
| 19 | 19 | import b4a from 'b4a' |
| 20 | 20 | import sodium from 'sodium-universal' |
| 21 | 21 | import { STORAGE_COMMITMENT_METADATA_OVERHEAD_BYTES } from '../config/storage-admission-authority.js' |
| 22 | import { verifyAppRelease } from './release-lifecycle.js' | |
| 22 | 23 | import { |
| 23 | 24 | compareVersions, |
| 24 | 25 | normalizeAvailabilityClass, |
| … | … | @@ -77,6 +78,36 @@ function sumBlockBytes (blocks) { |
| 77 | 78 | return total |
| 78 | 79 | } |
| 79 | 80 | |
| 81 | function compareAppReleaseRows (left, right) { | |
| 82 | const leftSequence = left?.release?.sequence | |
| 83 | const rightSequence = right?.release?.sequence | |
| 84 | const leftSigned = Number.isSafeInteger(leftSequence) && leftSequence > 0 | |
| 85 | const rightSigned = Number.isSafeInteger(rightSequence) && rightSequence > 0 | |
| 86 | if (leftSigned && rightSigned) { | |
| 87 | return leftSequence - rightSequence | |
| 88 | } | |
| 89 | if (leftSigned !== rightSigned) return leftSigned ? 1 : -1 | |
| 90 | return compareVersions(left?.version || '0.0.0', right?.version || '0.0.0') | |
| 91 | } | |
| 92 | ||
| 93 | function validatePersistedRelease (entry, appKey) { | |
| 94 | if (entry.release == null) return null | |
| 95 | const verified = verifyAppRelease(entry.release, { checkTime: false }) | |
| 96 | if (!verified.ok) throw registryInventoryError('invalid-release') | |
| 97 | const release = verified.release | |
| 98 | const publisherPubkey = typeof entry.publisherPubkey === 'string' | |
| 99 | ? entry.publisherPubkey.toLowerCase() | |
| 100 | : null | |
| 101 | if (release.driveKey !== appKey || | |
| 102 | release.appId !== entry.appId || | |
| 103 | release.version !== entry.version || | |
| 104 | release.publisherPubkey !== publisherPubkey || | |
| 105 | release.storageBudgetBytes !== entry.maxStorage) { | |
| 106 | throw registryInventoryError('release-context-mismatch') | |
| 107 | } | |
| 108 | return release | |
| 109 | } | |
| 110 | ||
| 80 | 111 | function validatedLegacyEntries (parsed) { |
| 81 | 112 | if (!parsed || typeof parsed !== 'object') throw registryInventoryError('unsupported-json-shape') |
| 82 | 113 | const entries = Array.isArray(parsed) ? parsed : Object.values(parsed) |
| … | … | @@ -92,6 +123,7 @@ function validatedLegacyEntries (parsed) { |
| 92 | 123 | validatePersistedStorageBound(entry) |
| 93 | 124 | validatePersistedStorageProof(entry) |
| 94 | 125 | validatePersistedMetadataBudget(entry) |
| 126 | validatePersistedRelease(entry, appKey) | |
| 95 | 127 | if (b4a.byteLength(JSON.stringify(entry)) + APP_REGISTRY_LEGACY_ROW_PREFLIGHT_MARGIN_BYTES > |
| 96 | 128 | STORAGE_COMMITMENT_METADATA_OVERHEAD_BYTES) { |
| 97 | 129 | throw registryInventoryError('storage-metadata-row-exceeds-commitment') |
| … | … | @@ -153,7 +185,7 @@ const MAX_TOMBSTONES = 5000 |
| 153 | 185 | |
| 154 | 186 | /** |
| 155 | 187 | * Collapse already-built catalog rows: for `app`-type rows sharing an appId, |
| 156 | * keep only the highest version. The same latest-version-wins rule catalog() | |
| 188 | * keep the highest signed release sequence (or legacy version). The same rule catalog() | |
| 157 | 189 | * applies inline (see ~`if (type === 'app')` below); factored here so the P2P |
| 158 | 190 | * broadcast can't drift from the HTTP view. Rows that are non-`app` or carry a |
| 159 | 191 | * null id (redacted/blind) are never collapsed — they pass through untouched. |
| … | … | @@ -168,7 +200,7 @@ function dedupLatestByAppId (rows, idField = 'appId') { |
| 168 | 200 | if (idx === undefined) { |
| 169 | 201 | seen.set(id, out.length) |
| 170 | 202 | out.push(r) |
| 171 | } else if (compareVersions(r.version || '0.0.0', out[idx].version || '0.0.0') > 0) { | |
| 203 | } else if (compareAppReleaseRows(r, out[idx]) > 0) { | |
| 172 | 204 | out[idx] = r |
| 173 | 205 | } |
| 174 | 206 | } |
| … | … | @@ -991,12 +1023,12 @@ export class AppRegistry extends EventEmitter { |
| 991 | 1023 | custodyIntentId: entry.custodyIntentId || null |
| 992 | 1024 | }, entry, opts) |
| 993 | 1025 | |
| 994 | // Dedup app entries by appId — keep latest version | |
| 1026 | // Dedup app entries by appId — signed sequence, legacy version fallback. | |
| 995 | 1027 | if (type === 'app') { |
| 996 | 1028 | const existingIdx = seen.get(appId) |
| 997 | 1029 | if (existingIdx !== undefined) { |
| 998 | 1030 | const existing = items[existingIdx] |
| 999 | if (compareVersions(catalogEntry.version, existing.version) > 0) { | |
| 1031 | if (compareAppReleaseRows(catalogEntry, existing) > 0) { | |
| 1000 | 1032 | items[existingIdx] = catalogEntry |
| 1001 | 1033 | } |
| 1002 | 1034 | } else { |
| … | … | @@ -1037,6 +1069,7 @@ export class AppRegistry extends EventEmitter { |
| 1037 | 1069 | parentKey: redacted ? null : (entry.parentKey || null), |
| 1038 | 1070 | mountPath: redacted ? null : (entry.mountPath || null), |
| 1039 | 1071 | version: redacted ? null : (entry.version || null), |
| 1072 | release: redacted ? null : (entry.release || null), | |
| 1040 | 1073 | discoveryKey: entry.discoveryKey |
| 1041 | 1074 | ? (redacted ? null : (typeof entry.discoveryKey === 'string' ? entry.discoveryKey : entry.discoveryKey.toString('hex'))) |
| 1042 | 1075 | : null, |
| … | … | @@ -1067,7 +1100,7 @@ export class AppRegistry extends EventEmitter { |
| 1067 | 1100 | retainUntil: redacted ? null : (entry.retainUntil || null) |
| 1068 | 1101 | }) |
| 1069 | 1102 | } |
| 1070 | // Dedup app-type rows by appId (keep highest version) — catalog() already | |
| 1103 | // Dedup app-type rows by appId (signed sequence, legacy version fallback) — catalog() already | |
| 1071 | 1104 | // does this for the HTTP view, but the P2P broadcast previously emitted one |
| 1072 | 1105 | // row per registry entry, leaking superseded versions to peers. Redacted |
| 1073 | 1106 | // rows (appId null) + non-'app' types pass through untouched. |
| … | … | @@ -1184,6 +1217,7 @@ export class AppRegistry extends EventEmitter { |
| 1184 | 1217 | } else { |
| 1185 | 1218 | validatePersistedStorageBound(entry) |
| 1186 | 1219 | validatePersistedStorageProof(entry) |
| 1220 | validatePersistedRelease(entry, appKey) | |
| 1187 | 1221 | } |
| 1188 | 1222 | seen.add(appKey) |
| 1189 | 1223 | inventory.push({ appKey, entry, metadataBudget, tombstone }) |
| … | … | @@ -1534,6 +1568,7 @@ export class AppRegistry extends EventEmitter { |
| 1534 | 1568 | _hydrateEntry (appKey, entry) { |
| 1535 | 1569 | const hasStorageProof = STORAGE_PROOF_FIELDS.every(field => entry[field] != null) |
| 1536 | 1570 | const metadataBudget = validatePersistedMetadataBudget(entry) |
| 1571 | const release = validatePersistedRelease(entry, appKey) | |
| 1537 | 1572 | const hydrated = { |
| 1538 | 1573 | startedAt: entry.startedAt || entry.seededAt || Date.now(), |
| 1539 | 1574 | appId: entry.appId || entry.name || null, |
| … | … | @@ -1556,6 +1591,7 @@ export class AppRegistry extends EventEmitter { |
| 1556 | 1591 | shardIds: Array.isArray(entry.shardIds) ? entry.shardIds : null, |
| 1557 | 1592 | privacyTier: entry.privacyTier || 'public', |
| 1558 | 1593 | publisherPubkey: entry.publisherPubkey || null, |
| 1594 | release, | |
| 1559 | 1595 | durability: Number.isFinite(entry.durability) ? entry.durability : 0, |
| 1560 | 1596 | revocable: entry.revocable !== false, |
| 1561 | 1597 | categories: entry.categories || null, |
| … | … | @@ -1615,6 +1651,7 @@ export class AppRegistry extends EventEmitter { |
| 1615 | 1651 | retainUntil: e.retainUntil || null, |
| 1616 | 1652 | shardIds: Array.isArray(e.shardIds) ? e.shardIds : null, |
| 1617 | 1653 | publisherPubkey: e.publisherPubkey || null, |
| 1654 | release: e.release || null, | |
| 1618 | 1655 | durability: Number.isFinite(e.durability) ? e.durability : 0, |
| 1619 | 1656 | revocable: e.revocable !== false, |
| 1620 | 1657 | maxStorage: Number.isSafeInteger(e.maxStorage) && e.maxStorage > 0 |
| … | … | @@ -1634,6 +1671,7 @@ export class AppRegistry extends EventEmitter { |
| 1634 | 1671 | // field can be coerced to null below and silently turn a poisoned partial |
| 1635 | 1672 | // proof into a legacy-looking row that fails only on the next restart. |
| 1636 | 1673 | const hasStorageProof = validatePersistedStorageProof(entry) |
| 1674 | const release = validatePersistedRelease(entry, appKey) | |
| 1637 | 1675 | const shape = { |
| 1638 | 1676 | appKey, |
| 1639 | 1677 | appId: entry.appId || null, |
| … | … | @@ -1656,6 +1694,7 @@ export class AppRegistry extends EventEmitter { |
| 1656 | 1694 | shardIds: Array.isArray(entry.shardIds) ? entry.shardIds : null, |
| 1657 | 1695 | privacyTier: entry.privacyTier || 'public', |
| 1658 | 1696 | publisherPubkey: entry.publisherPubkey || null, |
| 1697 | release, | |
| 1659 | 1698 | durability: Number.isFinite(entry.durability) ? entry.durability : 0, |
| 1660 | 1699 | revocable: entry.revocable !== false, |
| 1661 | 1700 | categories: entry.categories || null, |
| … | … | @@ -9,6 +9,15 @@ export { |
| 9 | 9 | parseSeedProtocolHandshake, |
| 10 | 10 | evaluateSeedProtocolHandshake |
| 11 | 11 | } from './protocol/seed-request.js' |
| 12 | export { | |
| 13 | APP_RELEASE_PROTOCOL_VERSION, | |
| 14 | APP_RELEASE_SIGNATURE_DOMAIN, | |
| 15 | MAX_RELEASE_ROLLBACK_WINDOW, | |
| 16 | hashReleaseTree, | |
| 17 | serializeAppReleaseForSigning, | |
| 18 | signAppRelease, | |
| 19 | verifyAppRelease | |
| 20 | } from './release-lifecycle.js' | |
| 12 | 21 | export { CircuitRelay } from './protocol/relay-circuit.js' |
| 13 | 22 | export { ProofOfRelay } from './protocol/proof-of-relay.js' |
| 14 | 23 | export { BandwidthReceipt } from './protocol/bandwidth-receipt.js' |
| … | … | @@ -9,6 +9,9 @@ import { isAbortError } from './lifecycle-scope.js' |
| 9 | 9 | import { verifyShareBundleForRelay } from '../pvss.js' |
| 10 | 10 | import { STORAGE_SHARE_BUNDLE_MAX_BYTES } from '../../config/storage-admission-authority.js' |
| 11 | 11 | import { positiveStorageBound } from '../../config/storage-cap.js' |
| 12 | import { hashReleaseTree, verifyAppRelease } from '../release-lifecycle.js' | |
| 13 | import { EvictionManager } from './eviction.js' | |
| 14 | import { compareSupersessionEntries } from './dedup-report.js' | |
| 12 | 15 | import { |
| 13 | 16 | isValidHexKey, |
| 14 | 17 | normalizeAvailabilityClass, |
| … | … | @@ -21,6 +24,46 @@ import { |
| 21 | 24 | // external callers from forging the serve-only path through JSON/options. |
| 22 | 25 | const STORAGE_RECOVERY_INGRESS = Symbol('storage-recovery-ingress') |
| 23 | 26 | |
| 27 | function findManifestConflict (appRegistry, appId, appKey, version, release = null) { | |
| 28 | let selected = null | |
| 29 | for (const [candidateKey, entry] of appRegistry.apps) { | |
| 30 | if (candidateKey === appKey || !entry || normalizeContentType(entry.type, 'app') !== 'app' || entry.appId !== appId) continue | |
| 31 | const candidateVersion = entry.version || '0.0.0' | |
| 32 | if (!selected || compareSupersessionEntries(entry, selected.entry) > 0) { | |
| 33 | selected = { key: candidateKey, version: candidateVersion, entry } | |
| 34 | } | |
| 35 | } | |
| 36 | if (!selected) return { conflict: false } | |
| 37 | return { | |
| 38 | conflict: true, | |
| 39 | existingKey: selected.key, | |
| 40 | existingVersion: selected.version, | |
| 41 | existingEntry: selected.entry, | |
| 42 | shouldReplace: compareSupersessionEntries({ version, release }, selected.entry) >= 0 | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | function releaseContextError (release, context) { | |
| 47 | if (release.driveKey !== context.appKey) return 'release driveKey does not match the seeded drive' | |
| 48 | if (release.appId !== context.appId) return 'release appId does not match manifest id' | |
| 49 | if (release.version !== context.version) return 'release version does not match manifest version' | |
| 50 | if (context.seedPublisher && release.publisherPubkey !== context.seedPublisher.toLowerCase()) { | |
| 51 | return 'release publisher does not match signed seed publisher' | |
| 52 | } | |
| 53 | if (context.maxStorage && release.storageBudgetBytes !== context.maxStorage) { | |
| 54 | return 'release storage budget does not match seed storage bound' | |
| 55 | } | |
| 56 | if (context.priorRelease) { | |
| 57 | const prior = context.priorRelease | |
| 58 | if (release.signature === prior.signature) return null | |
| 59 | if (release.publisherPubkey !== prior.publisherPubkey) return 'release publisher changed on the same drive' | |
| 60 | if (release.sequence <= prior.sequence) return 'release sequence did not advance' | |
| 61 | if (release.generation !== prior.generation) return 'release generation changed without key rotation' | |
| 62 | if (release.previousDriveKey !== null) return 'same-drive release cannot declare a predecessor' | |
| 63 | } | |
| 64 | return null | |
| 65 | } | |
| 66 | ||
| 24 | 67 | function seedAdmissionTimeoutError (timeoutMs) { |
| 25 | 68 | const err = new Error(`Seed admission timed out after ${timeoutMs}ms`) |
| 26 | 69 | err.code = 'STORAGE_SEED_ADMISSION_TIMEOUT' |
| … | … | @@ -91,6 +134,30 @@ export class AppLifecycle extends EventEmitter { |
| 91 | 134 | return this.node.appRegistry.apps |
| 92 | 135 | } |
| 93 | 136 | |
| 137 | async _reclaimReleaseRollback ({ appId, publisherPubkey, retainKeys }) { | |
| 138 | const node = this.node | |
| 139 | let manager = node.eviction || null | |
| 140 | if (!manager && node.store) { | |
| 141 | manager = new EvictionManager({ | |
| 142 | appRegistry: node.appRegistry, | |
| 143 | seedingRegistry: node.seedingRegistry || {}, | |
| 144 | storageAccounting: node.storageAccounting || { getBytes: () => null }, | |
| 145 | diskMonitor: node.diskMonitor || {}, | |
| 146 | getReplicationHealth: () => new Map(), | |
| 147 | myPubkeyHex: 'release-retention', | |
| 148 | unseed: (appKeyHex, opts) => this.unseedApp(appKeyHex, opts), | |
| 149 | store: node.store | |
| 150 | }) | |
| 151 | } | |
| 152 | if (!manager || typeof manager.reclaimSuperseded !== 'function') return null | |
| 153 | return manager.reclaimSuperseded({ | |
| 154 | dryRun: false, | |
| 155 | appId, | |
| 156 | publisherPubkey, | |
| 157 | retainKeys | |
| 158 | }) | |
| 159 | } | |
| 160 | ||
| 94 | 161 | _joinDriveDiscovery (appKeyHex, discoveryKey, handles = null) { |
| 95 | 162 | const handle = this.node.swarm.join(discoveryKey, { server: true, client: true }) |
| 96 | 163 | const entry = this.node.appRegistry.get(appKeyHex) |
| … | … | @@ -504,6 +571,7 @@ export class AppLifecycle extends EventEmitter { |
| 504 | 571 | throw err |
| 505 | 572 | } |
| 506 | 573 | this._recordCustodyReceiptOnRepin(appKeyHex, existing, normalizedOpts) |
| 574 | this._refreshExistingSeed(appKeyHex, existing, normalizedOpts) | |
| 507 | 575 | return { discoveryKey: dkHex, alreadySeeded: true } |
| 508 | 576 | } |
| 509 | 577 | // else: placeholder entry from load() — fall through to seed properly. |
| … | … | @@ -547,6 +615,7 @@ export class AppLifecycle extends EventEmitter { |
| 547 | 615 | throw err |
| 548 | 616 | } |
| 549 | 617 | this._recordCustodyReceiptOnRepin(appKeyHex, existing, opts) |
| 618 | this._refreshExistingSeed(appKeyHex, existing, opts) | |
| 550 | 619 | return { discoveryKey: dkHex, alreadySeeded: true } |
| 551 | 620 | } |
| 552 | 621 | // else: placeholder entry from load() — fall through. |
| … | … | @@ -751,6 +820,7 @@ export class AppLifecycle extends EventEmitter { |
| 751 | 820 | retainUntil: Number.isFinite(opts.retainUntil) ? opts.retainUntil : null, |
| 752 | 821 | shardIds: Array.isArray(opts.shardIds) ? opts.shardIds : null, |
| 753 | 822 | publisherPubkey, |
| 823 | release: opts.release || null, | |
| 754 | 824 | revocable, |
| 755 | 825 | unseedFreezeMs, |
| 756 | 826 | durability, |
| … | … | @@ -914,6 +984,15 @@ export class AppLifecycle extends EventEmitter { |
| 914 | 984 | return promise |
| 915 | 985 | } |
| 916 | 986 | |
| 987 | _refreshExistingSeed (appKeyHex, existing, opts) { | |
| 988 | const drive = existing?.drive | |
| 989 | if (!drive || drive.closed || drive.closing || existing._replicating) return false | |
| 990 | existing._replicating = true | |
| 991 | this._trackEagerReplicate(appKeyHex, drive, opts, { source: 'repin-refresh' }) | |
| 992 | .finally(() => { existing._replicating = false }) | |
| 993 | return true | |
| 994 | } | |
| 995 | ||
| 917 | 996 | async _downloadProvedDriveRanges (drive, proof, opts = {}) { |
| 918 | 997 | const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 120_000 |
| 919 | 998 | const signal = opts.signal || null |
| … | … | @@ -1537,6 +1616,50 @@ export class AppLifecycle extends EventEmitter { |
| 1537 | 1616 | } |
| 1538 | 1617 | } |
| 1539 | 1618 | |
| 1619 | async _verifyReleaseTree (drive, release, proof = null) { | |
| 1620 | let snapshot = drive | |
| 1621 | let ownsSnapshot = false | |
| 1622 | const version = proof?.driveVersion ?? drive?.version | |
| 1623 | if (typeof drive?.checkout === 'function' && Number.isSafeInteger(version) && version > 0) { | |
| 1624 | snapshot = drive.checkout(version) | |
| 1625 | ownsSnapshot = true | |
| 1626 | } | |
| 1627 | ||
| 1628 | try { | |
| 1629 | if (!snapshot || typeof snapshot.get !== 'function' || typeof snapshot.list !== 'function') { | |
| 1630 | return { ok: false, error: 'release tree cannot be inspected at the pinned drive version' } | |
| 1631 | } | |
| 1632 | if (typeof snapshot.ready === 'function') await snapshot.ready() | |
| 1633 | ||
| 1634 | const pinnedManifest = await snapshot.get('/manifest.json', { wait: false }) | |
| 1635 | if (!pinnedManifest) return { ok: false, error: 'release manifest is absent from the pinned drive version' } | |
| 1636 | const envelope = JSON.parse(pinnedManifest.toString())?.hiverelay?.release | |
| 1637 | if (!envelope || envelope.signature !== release.signature) { | |
| 1638 | return { ok: false, error: 'release manifest changed while its content tree was being verified' } | |
| 1639 | } | |
| 1640 | ||
| 1641 | const files = [] | |
| 1642 | for await (const entry of snapshot.list('/')) { | |
| 1643 | const path = entry?.key | |
| 1644 | if (typeof path !== 'string' || path === '/manifest.json' || path === '/.hiverelay/rotation.json') continue | |
| 1645 | const content = await snapshot.get(path, { wait: false }) | |
| 1646 | if (content === null) return { ok: false, error: `release tree file is unavailable: ${path}` } | |
| 1647 | files.push({ path, content }) | |
| 1648 | } | |
| 1649 | const actualTreeHash = hashReleaseTree(files) | |
| 1650 | if (actualTreeHash !== release.treeHash) { | |
| 1651 | return { ok: false, error: 'release treeHash does not match the pinned Hyperdrive contents' } | |
| 1652 | } | |
| 1653 | return { ok: true } | |
| 1654 | } catch (err) { | |
| 1655 | return { ok: false, error: err.message || 'release tree verification failed' } | |
| 1656 | } finally { | |
| 1657 | if (ownsSnapshot && snapshot && typeof snapshot.close === 'function') { | |
| 1658 | try { await snapshot.close() } catch (_) {} | |
| 1659 | } | |
| 1660 | } | |
| 1661 | } | |
| 1662 | ||
| 1540 | 1663 | /** |
| 1541 | 1664 | * Read manifest.json from a drive and deduplicate by appId. |
| 1542 | 1665 | * If an older version of the same app is already seeded, unseed it. |
| … | … | @@ -1580,7 +1703,78 @@ export class AppLifecycle extends EventEmitter { |
| 1580 | 1703 | ? manifest.mountPath.trim() |
| 1581 | 1704 | : existing?.mountPath || null |
| 1582 | 1705 | |
| 1583 | // Update this entry's metadata via the registry | |
| 1706 | // Signed release metadata is the only authority that can rotate an app | |
| 1707 | // onto a new drive without immediately retiring its predecessor. Bind it | |
| 1708 | // to the pinned manifest, seed-request publisher, and declared storage | |
| 1709 | // bound before persisting any manifest-derived fields. | |
| 1710 | const releaseEnvelope = manifest.hiverelay?.release | |
| 1711 | let signedRelease = null | |
| 1712 | if (releaseEnvelope !== undefined) { | |
| 1713 | const verified = verifyAppRelease(releaseEnvelope) | |
| 1714 | const releaseContext = { | |
| 1715 | appKey: appKeyHex, | |
| 1716 | appId, | |
| 1717 | version, | |
| 1718 | seedPublisher: existing?.publisherPubkey || null, | |
| 1719 | maxStorage: existing?.maxStorage || null, | |
| 1720 | priorRelease: existing?.release || null | |
| 1721 | } | |
| 1722 | let contextError = verified.ok | |
| 1723 | ? releaseContextError(verified.release, releaseContext) | |
| 1724 | : verified.error | |
| 1725 | if (verified.ok && !contextError) { | |
| 1726 | const treeVerification = await this._verifyReleaseTree(drive, verified.release, proof) | |
| 1727 | if (!treeVerification.ok) contextError = treeVerification.error | |
| 1728 | } | |
| 1729 | if (!verified.ok || contextError) { | |
| 1730 | this.emit('app-release-rejected', { | |
| 1731 | appId, | |
| 1732 | appKey: appKeyHex, | |
| 1733 | version, | |
| 1734 | reason: contextError || verified.error | |
| 1735 | }) | |
| 1736 | await this.unseedApp(appKeyHex) | |
| 1737 | return | |
| 1738 | } | |
| 1739 | signedRelease = verified.release | |
| 1740 | } | |
| 1741 | ||
| 1742 | // Scan the actual registry instead of trusting byAppId. seedApp may have | |
| 1743 | // already indexed the incoming appId, and byAppId is last-writer-wins; | |
| 1744 | // using it here can hide the predecessor we must validate and retain. | |
| 1745 | const conflict = contentType === 'app' && appId | |
| 1746 | ? findManifestConflict(node.appRegistry, appId, appKeyHex, version, signedRelease) | |
| 1747 | : { conflict: false } | |
| 1748 | const sameDriveContinuation = Boolean(signedRelease && existing?.release) | |
| 1749 | ||
| 1750 | if (signedRelease && conflict.conflict && conflict.shouldReplace) { | |
| 1751 | const predecessorPublisher = conflict.existingEntry.publisherPubkey || conflict.existingEntry.release?.publisherPubkey || null | |
| 1752 | let transitionError = null | |
| 1753 | if (!predecessorPublisher || predecessorPublisher.toLowerCase() !== signedRelease.publisherPubkey) { | |
| 1754 | transitionError = 'release publisher does not match predecessor publisher' | |
| 1755 | } else if (!sameDriveContinuation && signedRelease.previousDriveKey !== conflict.existingKey) { | |
| 1756 | transitionError = 'signed release does not name the current predecessor drive' | |
| 1757 | } else if (!sameDriveContinuation && conflict.existingEntry.release) { | |
| 1758 | const prior = conflict.existingEntry.release | |
| 1759 | if (signedRelease.sequence <= prior.sequence) transitionError = 'rotated release sequence did not advance' | |
| 1760 | else if (signedRelease.generation !== prior.generation + 1) transitionError = 'rotated release generation must advance by one' | |
| 1761 | } | |
| 1762 | if (transitionError) { | |
| 1763 | this.emit('app-release-rejected', { | |
| 1764 | appId, | |
| 1765 | appKey: appKeyHex, | |
| 1766 | version, | |
| 1767 | reason: transitionError | |
| 1768 | }) | |
| 1769 | await this.unseedApp(appKeyHex) | |
| 1770 | return | |
| 1771 | } | |
| 1772 | } | |
| 1773 | ||
| 1774 | // A signed release becomes deletion authority for superseded drives, so | |
| 1775 | // capture the pre-index state and persist the accepted envelope before | |
| 1776 | // any reclamation can run. | |
| 1777 | const previousEntrySnapshot = existing ? { ...existing } : null | |
| 1584 | 1778 | node.appRegistry.update(appKeyHex, { |
| 1585 | 1779 | type: contentType, |
| 1586 | 1780 | parentKey, |
| … | … | @@ -1599,14 +1793,36 @@ export class AppLifecycle extends EventEmitter { |
| 1599 | 1793 | name: manifest.name || appId, |
| 1600 | 1794 | description: manifest.description || '', |
| 1601 | 1795 | author: manifest.author || null, |
| 1602 | categories: manifest.categories || null | |
| 1603 | }) | |
| 1796 | categories: manifest.categories || null, | |
| 1797 | publisherPubkey: signedRelease?.publisherPubkey || existing?.publisherPubkey || null, | |
| 1798 | release: signedRelease || existing?.release || null | |
| 1799 | }, signedRelease ? { persist: false } : {}) | |
| 1800 | ||
| 1801 | if (signedRelease && typeof node.appRegistry.persistEntry === 'function') { | |
| 1802 | try { | |
| 1803 | await node.appRegistry.persistEntry(appKeyHex, { throwOnError: true }) | |
| 1804 | } catch (err) { | |
| 1805 | if (sameDriveContinuation && previousEntrySnapshot) { | |
| 1806 | node.appRegistry.set(appKeyHex, previousEntrySnapshot, { persist: false }) | |
| 1807 | } | |
| 1808 | this.emit('app-release-rejected', { | |
| 1809 | appId, | |
| 1810 | appKey: appKeyHex, | |
| 1811 | version, | |
| 1812 | reason: 'release registry persistence failed: ' + (err.message || String(err)) | |
| 1813 | }) | |
| 1814 | if (!sameDriveContinuation) await this.unseedApp(appKeyHex) | |
| 1815 | return | |
| 1816 | } | |
| 1817 | } | |
| 1604 | 1818 | |
| 1605 | 1819 | if (contentType !== 'app') return |
| 1606 | 1820 | if (!appId) return |
| 1607 | 1821 | |
| 1608 | // Check for version conflicts with existing apps | |
| 1609 | const conflict = node.appRegistry.checkConflict(appId, appKeyHex, version) | |
| 1822 | // Check for version conflicts with existing apps. Legacy unsigned | |
| 1823 | // releases preserve the old immediate-replacement behavior. A verified | |
| 1824 | // signed transition retains exactly the publisher-declared rollback set | |
| 1825 | // and asks the existing safe purge path to reclaim only older drives. | |
| 1610 | 1826 | if (conflict.conflict) { |
| 1611 | 1827 | if (conflict.shouldReplace) { |
| 1612 | 1828 | this.emit('app-replaced', { |
| … | … | @@ -1614,10 +1830,34 @@ export class AppLifecycle extends EventEmitter { |
| 1614 | 1830 | oldKey: conflict.existingKey, |
| 1615 | 1831 | oldVersion: conflict.existingVersion, |
| 1616 | 1832 | newKey: appKeyHex, |
| 1617 | newVersion: version | |
| 1833 | newVersion: version, | |
| 1834 | signedRotation: Boolean(signedRelease && !sameDriveContinuation) | |
| 1618 | 1835 | }) |
| 1619 | await this.unseedApp(conflict.existingKey) | |
| 1836 | if (signedRelease) { | |
| 1837 | await this._reclaimReleaseRollback({ | |
| 1838 | appId, | |
| 1839 | publisherPubkey: signedRelease.publisherPubkey, | |
| 1840 | retainKeys: signedRelease.rollbackDriveKeys | |
| 1841 | }) | |
| 1842 | } else { | |
| 1843 | await this.unseedApp(conflict.existingKey) | |
| 1844 | } | |
| 1620 | 1845 | } else { |
| 1846 | const currentRelease = conflict.existingEntry.release | |
| 1847 | const rollbackProtected = currentRelease && | |
| 1848 | currentRelease.publisherPubkey === (signedRelease?.publisherPubkey || existing?.publisherPubkey) && | |
| 1849 | Array.isArray(currentRelease.rollbackDriveKeys) && | |
| 1850 | currentRelease.rollbackDriveKeys.includes(appKeyHex) | |
| 1851 | if (rollbackProtected) { | |
| 1852 | // A retained predecessor can receive its signed rotation pointer | |
| 1853 | // after the successor is indexed. Re-indexing its older manifest | |
| 1854 | // must not accidentally unseed the rollback copy. AppRegistry's | |
| 1855 | // update above is last-writer-wins, so restore the canonical hint | |
| 1856 | // to the newer drive as well (catalog remains version/sequence | |
| 1857 | // ordered independently). | |
| 1858 | if (node.appRegistry.byAppId) node.appRegistry.byAppId.set(appId, conflict.existingKey) | |
| 1859 | return | |
| 1860 | } | |
| 1621 | 1861 | this.emit('app-version-rejected', { |
| 1622 | 1862 | appId, |
| 1623 | 1863 | rejectedKey: appKeyHex, |
| … | … | @@ -36,8 +36,7 @@ import { EventEmitter } from 'events' |
| 36 | 36 | import b4a from 'b4a' |
| 37 | 37 | import Hyperdrive from 'hyperdrive' |
| 38 | 38 | import { raceTimeout } from './storage-accounting.js' |
| 39 | import { buildDedupReport } from './dedup-report.js' | |
| 40 | import { compareVersions } from '../constants.js' | |
| 39 | import { buildDedupReport, compareSupersessionEntries } from './dedup-report.js' | |
| 41 | 40 | |
| 42 | 41 | const DEFAULTS = { |
| 43 | 42 | enabled: false, |
| … | … | @@ -245,12 +244,22 @@ export class EvictionManager extends EventEmitter { |
| 245 | 244 | * @param {object} [opts] |
| 246 | 245 | * @param {boolean} [opts.dryRun=true] report-only (no deletes) by default |
| 247 | 246 | * @param {number} [opts.retainVersions=0] keep the N newest superseded versions per appId |
| 247 | * @param {string[]} [opts.retainKeys] exact signed rollback drive keys to retain | |
| 248 | * @param {string} [opts.appId] limit reclamation to one exact app id | |
| 249 | * @param {string} [opts.publisherPubkey] limit reclamation to one publisher | |
| 248 | 250 | * @param {number} [opts.max=Infinity] cap reclaims this pass |
| 249 | 251 | * @returns {{dryRun, reclaimed:[], skipped:[], freedBytes, candidates}} |
| 250 | 252 | */ |
| 251 | 253 | async reclaimSuperseded (opts = {}) { |
| 252 | 254 | const dryRun = opts.dryRun !== false |
| 253 | 255 | const retainVersions = Number.isFinite(opts.retainVersions) ? Math.max(0, opts.retainVersions) : 0 |
| 256 | const retainKeys = Array.isArray(opts.retainKeys) | |
| 257 | ? new Set(opts.retainKeys.filter(key => typeof key === 'string').map(key => key.toLowerCase())) | |
| 258 | : null | |
| 259 | const appId = typeof opts.appId === 'string' && opts.appId.length > 0 ? opts.appId : null | |
| 260 | const publisherPubkey = typeof opts.publisherPubkey === 'string' && opts.publisherPubkey.length > 0 | |
| 261 | ? opts.publisherPubkey.toLowerCase() | |
| 262 | : null | |
| 254 | 263 | const max = Number.isFinite(opts.max) ? opts.max : Infinity |
| 255 | 264 | const now = Date.now() |
| 256 | 265 | |
| … | … | @@ -260,9 +269,13 @@ export class EvictionManager extends EventEmitter { |
| 260 | 269 | let freedBytes = 0 |
| 261 | 270 | |
| 262 | 271 | for (const group of report.supersededVersions.groups) { |
| 272 | if (appId && group.appId !== appId) continue | |
| 273 | if (publisherPubkey && group.publisherPubkey?.toLowerCase() !== publisherPubkey) continue | |
| 263 | 274 | const current = this.deps.appRegistry.get(group.current) |
| 264 | 275 | let candidates = group.superseded |
| 265 | if (retainVersions > 0) { | |
| 276 | if (retainKeys) { | |
| 277 | candidates = group.superseded.filter(candidate => !retainKeys.has(candidate.appKey.toLowerCase())) | |
| 278 | } else if (retainVersions > 0) { | |
| 266 | 279 | // Keep the retainVersions newest superseded versions; reclaim the rest. |
| 267 | 280 | // Deterministic order: version desc, then born-time desc, then appKey — |
| 268 | 281 | // so versionless/equal-version ties don't reclaim on iteration order. |
| … | … | @@ -271,7 +284,7 @@ export class EvictionManager extends EventEmitter { |
| 271 | 284 | const e = this.deps.appRegistry.get(s.appKey) || {} |
| 272 | 285 | return { ...s, version: e.version || '0.0.0', bornAt: e.seededAt || e.startedAt || 0 } |
| 273 | 286 | }) |
| 274 | .sort((a, b) => compareVersions(b.version, a.version) || (b.bornAt - a.bornAt) || (a.appKey < b.appKey ? -1 : a.appKey > b.appKey ? 1 : 0)) | |
| 287 | .sort((a, b) => compareSupersessionEntries(b, a) || (b.bornAt - a.bornAt) || (a.appKey < b.appKey ? -1 : a.appKey > b.appKey ? 1 : 0)) | |
| 275 | 288 | .slice(retainVersions) |
| 276 | 289 | } |
| 277 | 290 | for (const s of candidates) { |
| … | … | @@ -285,7 +298,7 @@ export class EvictionManager extends EventEmitter { |
| 285 | 298 | // against drift between report-build and now. |
| 286 | 299 | if (!current || |
| 287 | 300 | !entry.publisherPubkey || entry.publisherPubkey !== current.publisherPubkey || |
| 288 | compareVersions(entry.version || '0.0.0', current.version || '0.0.0') >= 0) { | |
| 301 | compareSupersessionEntries(entry, current) >= 0) { | |
| 289 | 302 | skipped.push({ appKey: s.appKey, reason: 'supersession-unverified' }) |
| 290 | 303 | continue |
| 291 | 304 | } |
| … | … | @@ -671,7 +671,7 @@ export class RelayNode extends EventEmitter { |
| 671 | 671 | this.appRegistry = new AppRegistry(this.config.storage) |
| 672 | 672 | this.appLifecycle = new AppLifecycle(this) |
| 673 | 673 | // Forward lifecycle events so existing listeners on RelayNode keep working |
| 674 | for (const ev of ['seeding', 'unseeded', 'reseeded', 'reseed-error', 'app-replaced', 'app-version-rejected']) { | |
| 674 | for (const ev of ['seeding', 'unseeded', 'reseeded', 'reseed-error', 'app-replaced', 'app-version-rejected', 'app-release-rejected']) { | |
| 675 | 675 | this.appLifecycle.on(ev, (payload) => this._emitSafely(ev, payload)) |
| 676 | 676 | } |
| 677 | 677 | this.connections = new Map() // conn -> { lastActivity } |
| … | … | @@ -0,0 +1,216 @@ |
| 1 | import b4a from 'b4a' | |
| 2 | import sodium from 'sodium-universal' | |
| 3 | ||
| 4 | export const APP_RELEASE_PROTOCOL_VERSION = 1 | |
| 5 | export const APP_RELEASE_SIGNATURE_DOMAIN = 'hiverelay.app-release.v1' | |
| 6 | export const MAX_RELEASE_ROLLBACK_WINDOW = 32 | |
| 7 | ||
| 8 | const RELEASE_FIELDS = new Set([ | |
| 9 | 'protocolVersion', | |
| 10 | 'appId', | |
| 11 | 'version', | |
| 12 | 'sequence', | |
| 13 | 'generation', | |
| 14 | 'driveKey', | |
| 15 | 'previousDriveKey', | |
| 16 | 'rotationReason', | |
| 17 | 'storageBudgetBytes', | |
| 18 | 'rollbackWindow', | |
| 19 | 'rollbackDriveKeys', | |
| 20 | 'treeHash', | |
| 21 | 'issuedAt', | |
| 22 | 'publisherPubkey', | |
| 23 | 'signature' | |
| 24 | ]) | |
| 25 | ||
| 26 | function hexKey (value, bytes) { | |
| 27 | return typeof value === 'string' && value.length === bytes * 2 && /^[0-9a-f]+$/i.test(value) | |
| 28 | ? value.toLowerCase() | |
| 29 | : null | |
| 30 | } | |
| 31 | ||
| 32 | function safeText (value, maxBytes) { | |
| 33 | if (typeof value !== 'string' || value.length === 0) return null | |
| 34 | for (let i = 0; i < value.length; i++) { | |
| 35 | const code = value.charCodeAt(i) | |
| 36 | if (code <= 0x1f || code === 0x7f) return null | |
| 37 | } | |
| 38 | return b4a.byteLength(value) <= maxBytes ? value : null | |
| 39 | } | |
| 40 | ||
| 41 | function normalizeRelease (input, opts = {}) { | |
| 42 | if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('release must be an object') | |
| 43 | for (const field of Object.keys(input)) { | |
| 44 | if (!RELEASE_FIELDS.has(field)) throw new Error(`unknown release field: ${field}`) | |
| 45 | } | |
| 46 | ||
| 47 | if (input.protocolVersion !== APP_RELEASE_PROTOCOL_VERSION) throw new Error('unsupported release protocolVersion') | |
| 48 | const appId = safeText(input.appId, 128) | |
| 49 | if (!appId) throw new Error('release appId must be 1-128 safe UTF-8 bytes') | |
| 50 | const version = safeText(input.version, 64) | |
| 51 | if (!version) throw new Error('release version must be 1-64 safe UTF-8 bytes') | |
| 52 | if (!Number.isSafeInteger(input.sequence) || input.sequence < 1) throw new Error('release sequence must be a positive safe integer') | |
| 53 | if (!Number.isSafeInteger(input.generation) || input.generation < 1) throw new Error('release generation must be a positive safe integer') | |
| 54 | ||
| 55 | const driveKey = hexKey(input.driveKey, 32) | |
| 56 | if (!driveKey) throw new Error('release driveKey must be 64 hex characters') | |
| 57 | const previousDriveKey = input.previousDriveKey === null ? null : hexKey(input.previousDriveKey, 32) | |
| 58 | if (input.previousDriveKey !== null && !previousDriveKey) throw new Error('release previousDriveKey must be null or 64 hex characters') | |
| 59 | if (previousDriveKey === driveKey) throw new Error('release previousDriveKey must differ from driveKey') | |
| 60 | ||
| 61 | const rotationReason = input.rotationReason === null ? null : input.rotationReason | |
| 62 | if (previousDriveKey && rotationReason !== 'storage-budget') { | |
| 63 | throw new Error('release-key rotation must declare storage-budget') | |
| 64 | } | |
| 65 | if (!previousDriveKey && rotationReason !== null) { | |
| 66 | throw new Error('rotationReason requires previousDriveKey') | |
| 67 | } | |
| 68 | ||
| 69 | if (!Number.isSafeInteger(input.storageBudgetBytes) || input.storageBudgetBytes < 1) { | |
| 70 | throw new Error('release storageBudgetBytes must be a positive safe integer') | |
| 71 | } | |
| 72 | if (!Number.isSafeInteger(input.rollbackWindow) || input.rollbackWindow < 1 || input.rollbackWindow > MAX_RELEASE_ROLLBACK_WINDOW) { | |
| 73 | throw new Error(`release rollbackWindow must be in [1,${MAX_RELEASE_ROLLBACK_WINDOW}]`) | |
| 74 | } | |
| 75 | if (!Array.isArray(input.rollbackDriveKeys) || input.rollbackDriveKeys.length > input.rollbackWindow - 1) { | |
| 76 | throw new Error('release rollbackDriveKeys exceeds rollbackWindow') | |
| 77 | } | |
| 78 | const rollbackDriveKeys = [] | |
| 79 | const seen = new Set([driveKey]) | |
| 80 | for (const value of input.rollbackDriveKeys) { | |
| 81 | const key = hexKey(value, 32) | |
| 82 | if (!key) throw new Error('release rollbackDriveKeys must contain 64-hex keys') | |
| 83 | if (seen.has(key)) throw new Error('release rollbackDriveKeys must be unique and exclude driveKey') | |
| 84 | seen.add(key) | |
| 85 | rollbackDriveKeys.push(key) | |
| 86 | } | |
| 87 | if (previousDriveKey && input.rollbackWindow > 1 && !seen.has(previousDriveKey)) { | |
| 88 | throw new Error('release rollback window must retain its predecessor') | |
| 89 | } | |
| 90 | ||
| 91 | const treeHash = hexKey(input.treeHash, 32) | |
| 92 | if (!treeHash) throw new Error('release treeHash must be 64 hex characters') | |
| 93 | if (!Number.isSafeInteger(input.issuedAt) || input.issuedAt < 0) throw new Error('release issuedAt must be a non-negative safe integer') | |
| 94 | const maxFutureSkewMs = Number.isSafeInteger(opts.maxFutureSkewMs) ? opts.maxFutureSkewMs : 60_000 | |
| 95 | const now = Number.isSafeInteger(opts.now) ? opts.now : Date.now() | |
| 96 | if (opts.checkTime !== false && input.issuedAt > now + maxFutureSkewMs) throw new Error('release issuedAt is too far in the future') | |
| 97 | ||
| 98 | const publisherPubkey = hexKey(input.publisherPubkey, 32) | |
| 99 | if (!publisherPubkey) throw new Error('release publisherPubkey must be 64 hex characters') | |
| 100 | const signature = input.signature === undefined && opts.allowUnsigned | |
| 101 | ? null | |
| 102 | : hexKey(input.signature, 64) | |
| 103 | if (!signature && !opts.allowUnsigned) throw new Error('release signature must be 128 hex characters') | |
| 104 | ||
| 105 | return { | |
| 106 | protocolVersion: APP_RELEASE_PROTOCOL_VERSION, | |
| 107 | appId, | |
| 108 | version, | |
| 109 | sequence: input.sequence, | |
| 110 | generation: input.generation, | |
| 111 | driveKey, | |
| 112 | previousDriveKey, | |
| 113 | rotationReason, | |
| 114 | storageBudgetBytes: input.storageBudgetBytes, | |
| 115 | rollbackWindow: input.rollbackWindow, | |
| 116 | rollbackDriveKeys, | |
| 117 | treeHash, | |
| 118 | issuedAt: input.issuedAt, | |
| 119 | publisherPubkey, | |
| 120 | ...(signature ? { signature } : {}) | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | function signingFields (release) { | |
| 125 | return [ | |
| 126 | release.protocolVersion, | |
| 127 | release.appId, | |
| 128 | release.version, | |
| 129 | release.sequence, | |
| 130 | release.generation, | |
| 131 | release.driveKey, | |
| 132 | release.previousDriveKey, | |
| 133 | release.rotationReason, | |
| 134 | release.storageBudgetBytes, | |
| 135 | release.rollbackWindow, | |
| 136 | release.rollbackDriveKeys, | |
| 137 | release.treeHash, | |
| 138 | release.issuedAt, | |
| 139 | release.publisherPubkey | |
| 140 | ] | |
| 141 | } | |
| 142 | ||
| 143 | export function serializeAppReleaseForSigning (input) { | |
| 144 | const release = normalizeRelease(input, { allowUnsigned: true, checkTime: false }) | |
| 145 | return b4a.concat([ | |
| 146 | b4a.from(APP_RELEASE_SIGNATURE_DOMAIN), | |
| 147 | b4a.from([0]), | |
| 148 | b4a.from(JSON.stringify(signingFields(release))) | |
| 149 | ]) | |
| 150 | } | |
| 151 | ||
| 152 | export function signAppRelease (input, keyPair) { | |
| 153 | if (!keyPair || !b4a.isBuffer(keyPair.publicKey) || keyPair.publicKey.byteLength !== 32 || | |
| 154 | !b4a.isBuffer(keyPair.secretKey) || keyPair.secretKey.byteLength !== 64) { | |
| 155 | throw new Error('release keyPair must contain an Ed25519 publicKey and secretKey') | |
| 156 | } | |
| 157 | const publisherPubkey = b4a.toString(keyPair.publicKey, 'hex') | |
| 158 | const release = normalizeRelease({ ...input, publisherPubkey }, { allowUnsigned: true, checkTime: false }) | |
| 159 | const signature = b4a.alloc(64) | |
| 160 | sodium.crypto_sign_detached(signature, serializeAppReleaseForSigning(release), keyPair.secretKey) | |
| 161 | return { ...release, signature: b4a.toString(signature, 'hex') } | |
| 162 | } | |
| 163 | ||
| 164 | export function verifyAppRelease (input, opts = {}) { | |
| 165 | try { | |
| 166 | const release = normalizeRelease(input, opts) | |
| 167 | const valid = sodium.crypto_sign_verify_detached( | |
| 168 | b4a.from(release.signature, 'hex'), | |
| 169 | serializeAppReleaseForSigning(release), | |
| 170 | b4a.from(release.publisherPubkey, 'hex') | |
| 171 | ) | |
| 172 | if (!valid) return { ok: false, error: 'release signature does not verify' } | |
| 173 | return { ok: true, release } | |
| 174 | } catch (err) { | |
| 175 | return { ok: false, error: err.message } | |
| 176 | } | |
| 177 | } | |
| 178 | ||
| 179 | function uint32 (value) { | |
| 180 | const out = b4a.alloc(4) | |
| 181 | new DataView(out.buffer, out.byteOffset, out.byteLength).setUint32(0, value) | |
| 182 | return out | |
| 183 | } | |
| 184 | ||
| 185 | function uint64 (value) { | |
| 186 | const out = b4a.alloc(8) | |
| 187 | new DataView(out.buffer, out.byteOffset, out.byteLength).setBigUint64(0, BigInt(value)) | |
| 188 | return out | |
| 189 | } | |
| 190 | ||
| 191 | export function hashReleaseTree (files) { | |
| 192 | if (!Array.isArray(files)) throw new Error('release files must be an array') | |
| 193 | const normalized = files.map(file => { | |
| 194 | if (!file || typeof file.path !== 'string' || !file.path.startsWith('/') || file.path.includes('\\')) { | |
| 195 | throw new Error('release file paths must be absolute Hyperdrive paths') | |
| 196 | } | |
| 197 | return { | |
| 198 | path: file.path, | |
| 199 | content: b4a.isBuffer(file.content) ? file.content : b4a.from(file.content) | |
| 200 | } | |
| 201 | }).sort((a, b) => a.path.localeCompare(b.path)) | |
| 202 | ||
| 203 | const seen = new Set() | |
| 204 | const parts = [b4a.from('hiverelay.app-release-tree.v1'), b4a.from([0])] | |
| 205 | for (const file of normalized) { | |
| 206 | if (seen.has(file.path)) throw new Error(`duplicate release path: ${file.path}`) | |
| 207 | seen.add(file.path) | |
| 208 | const path = b4a.from(file.path) | |
| 209 | const contentHash = b4a.alloc(32) | |
| 210 | sodium.crypto_generichash(contentHash, file.content) | |
| 211 | parts.push(uint32(path.byteLength), path, uint64(file.content.byteLength), contentHash) | |
| 212 | } | |
| 213 | const treeHash = b4a.alloc(32) | |
| 214 | sodium.crypto_generichash(treeHash, b4a.concat(parts)) | |
| 215 | return b4a.toString(treeHash, 'hex') | |
| 216 | } |
| … | … | @@ -82,6 +82,33 @@ export const AUDIT_OWNED_DIFF_SLICES = Object.freeze([ |
| 82 | 82 | 'test/unit/audit-owned-diff.test.js' |
| 83 | 83 | ] |
| 84 | 84 | }, |
| 85 | { | |
| 86 | id: 'hyperdrive-release-lifecycle', | |
| 87 | title: 'Bounded Hyperdrive release rotation and scoped reclamation', | |
| 88 | files: [ | |
| 89 | 'docs/APP-RELEASE-LIFECYCLE.md', | |
| 90 | 'docs/PEAR-INTEGRATION.md', | |
| 91 | 'packages/core/core/app-registry.js', | |
| 92 | 'packages/core/core/index.js', | |
| 93 | 'packages/core/core/release-lifecycle.js', | |
| 94 | 'packages/core/core/relay-node/app-lifecycle.js', | |
| 95 | 'packages/core/core/relay-node/dedup-report.js', | |
| 96 | 'packages/core/core/relay-node/eviction.js', | |
| 97 | 'packages/core/core/relay-node/index.js', | |
| 98 | 'scripts/lib/audit-owned-diff.mjs', | |
| 99 | 'scripts/lib/publish-drive-sync.mjs', | |
| 100 | 'scripts/lib/release-publisher.mjs', | |
| 101 | 'scripts/publish-app.js', | |
| 102 | 'test/integration/publish-release-rotation.test.js', | |
| 103 | 'test/unit/app-release-rotation.test.js', | |
| 104 | 'test/unit/audit-owned-diff.test.js', | |
| 105 | 'test/unit/dedup-reclaim.test.js', | |
| 106 | 'test/unit/dedup-report.test.js', | |
| 107 | 'test/unit/publish-drive-sync.test.js', | |
| 108 | 'test/unit/release-lifecycle.test.js', | |
| 109 | 'test/unit/release-publisher.test.js' | |
| 110 | ] | |
| 111 | }, | |
| 85 | 112 | { |
| 86 | 113 | id: 'dispatch-route-helper', |
| 87 | 114 | title: 'HTTP dispatch route helper extraction', |
| … | … | @@ -0,0 +1,102 @@ |
| 1 | import b4a from 'b4a' | |
| 2 | ||
| 3 | function normalizeFile (file) { | |
| 4 | if (!file || typeof file.path !== 'string' || !file.path.startsWith('/')) { | |
| 5 | throw new TypeError('publish file path must be an absolute Hyperdrive path') | |
| 6 | } | |
| 7 | if (file.path === '/' || file.path.endsWith('/') || file.path.includes('\\')) { | |
| 8 | throw new TypeError('publish file path must identify a file') | |
| 9 | } | |
| 10 | const parts = file.path.slice(1).split('/') | |
| 11 | if (parts.some(part => part === '' || part === '.' || part === '..')) { | |
| 12 | throw new TypeError('publish file path must be canonical') | |
| 13 | } | |
| 14 | const content = b4a.isBuffer(file.content) ? file.content : b4a.from(file.content) | |
| 15 | return { path: file.path, content } | |
| 16 | } | |
| 17 | ||
| 18 | async function sameContent (drive, entry, content) { | |
| 19 | const blob = entry && entry.value && entry.value.blob | |
| 20 | if (!blob) return false | |
| 21 | const current = await drive.get(entry.key, { wait: false }) | |
| 22 | return current !== null && b4a.equals(current, content) | |
| 23 | } | |
| 24 | ||
| 25 | function writeDeduplicated (drive, path, content) { | |
| 26 | return new Promise((resolve, reject) => { | |
| 27 | const stream = drive.createWriteStream(path, { dedup: true }) | |
| 28 | stream.once('error', reject) | |
| 29 | stream.once('finish', resolve) | |
| 30 | stream.end(content) | |
| 31 | }) | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * Make a Hyperdrive's live file tree exactly match a release file set. | |
| 36 | * | |
| 37 | * Unchanged files are not written at all. Changed files use Hyperblobs block | |
| 38 | * deduplication against the previous value, so an update only appends changed | |
| 39 | * blocks plus its new block map. Paths absent from the release are deleted. | |
| 40 | * Metadata changes are committed together through one Hyperbee batch. | |
| 41 | */ | |
| 42 | export async function planPublishedFiles (drive, files) { | |
| 43 | const desired = new Map() | |
| 44 | for (const input of files) { | |
| 45 | const file = normalizeFile(input) | |
| 46 | if (desired.has(file.path)) throw new Error(`duplicate publish path: ${file.path}`) | |
| 47 | desired.set(file.path, file.content) | |
| 48 | } | |
| 49 | ||
| 50 | const existing = new Map() | |
| 51 | for await (const entry of drive.list('/')) existing.set(entry.key, entry) | |
| 52 | ||
| 53 | const removed = [] | |
| 54 | for (const path of existing.keys()) { | |
| 55 | if (!desired.has(path)) removed.push(path) | |
| 56 | } | |
| 57 | ||
| 58 | const writes = [] | |
| 59 | let unchanged = 0 | |
| 60 | for (const [path, content] of desired) { | |
| 61 | const entry = existing.get(path) | |
| 62 | if (entry && await sameContent(drive, entry, content)) { | |
| 63 | unchanged++ | |
| 64 | continue | |
| 65 | } | |
| 66 | writes.push({ path, content, type: entry ? 'change' : 'add' }) | |
| 67 | } | |
| 68 | ||
| 69 | return { | |
| 70 | desired, | |
| 71 | writes, | |
| 72 | removed, | |
| 73 | unchanged, | |
| 74 | contentBytes: writes.reduce((total, file) => total + file.content.byteLength, 0) | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | export async function syncPublishedFiles (drive, files, opts = {}) { | |
| 79 | const plan = opts.plan || await planPublishedFiles(drive, files) | |
| 80 | const { writes, removed, unchanged } = plan | |
| 81 | ||
| 82 | if (removed.length === 0 && writes.length === 0) { | |
| 83 | return { added: 0, changed: 0, removed: 0, unchanged } | |
| 84 | } | |
| 85 | ||
| 86 | const batch = drive.batch() | |
| 87 | try { | |
| 88 | for (const path of removed) await batch.del(path) | |
| 89 | for (const file of writes) await writeDeduplicated(batch, file.path, file.content) | |
| 90 | await batch.flush() | |
| 91 | } catch (err) { | |
| 92 | try { await batch.close() } catch {} | |
| 93 | throw err | |
| 94 | } | |
| 95 | ||
| 96 | return { | |
| 97 | added: writes.filter(file => file.type === 'add').length, | |
| 98 | changed: writes.filter(file => file.type === 'change').length, | |
| 99 | removed: removed.length, | |
| 100 | unchanged | |
| 101 | } | |
| 102 | } |
| … | … | @@ -0,0 +1,224 @@ |
| 1 | import b4a from 'b4a' | |
| 2 | import sodium from 'sodium-universal' | |
| 3 | import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { signAppRelease } from '../../packages/core/core/release-lifecycle.js' | |
| 6 | import { serializeSeedRequestForReplaySigning } from '../../packages/core/core/protocol/seed-request.js' | |
| 7 | ||
| 8 | export const DEFAULT_RELEASE_STORAGE_BUDGET = 1024 * 1024 * 1024 | |
| 9 | export const DEFAULT_RELEASE_ROLLBACK_WINDOW = 3 | |
| 10 | export const ROTATION_POINTER_RESERVE_BYTES = 64 * 1024 | |
| 11 | export const RELEASE_STATE_FILE = 'release-state.json' | |
| 12 | ||
| 13 | export function parseStorageBytes (input) { | |
| 14 | const units = { | |
| 15 | B: 1, | |
| 16 | KB: 1024, | |
| 17 | MB: 1024 ** 2, | |
| 18 | GB: 1024 ** 3, | |
| 19 | TB: 1024 ** 4, | |
| 20 | KIB: 1024, | |
| 21 | MIB: 1024 ** 2, | |
| 22 | GIB: 1024 ** 3, | |
| 23 | TIB: 1024 ** 4 | |
| 24 | } | |
| 25 | const match = String(input || '').trim().match(/^(\d+(?:\.\d+)?)\s*(TiB|GiB|MiB|KiB|TB|GB|MB|KB|B)?$/i) | |
| 26 | if (!match) return null | |
| 27 | const value = Math.floor(Number(match[1]) * units[(match[2] || 'B').toUpperCase()]) | |
| 28 | return Number.isSafeInteger(value) && value > 0 ? value : null | |
| 29 | } | |
| 30 | ||
| 31 | export function formatStorageBytes (bytes) { | |
| 32 | const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'] | |
| 33 | let value = bytes | |
| 34 | let unit = 0 | |
| 35 | while (value >= 1024 && unit < units.length - 1) { | |
| 36 | value /= 1024 | |
| 37 | unit++ | |
| 38 | } | |
| 39 | return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}` | |
| 40 | } | |
| 41 | ||
| 42 | export async function loadReleaseState (storagePath) { | |
| 43 | try { | |
| 44 | const parsed = JSON.parse(await readFile(join(storagePath, RELEASE_STATE_FILE), 'utf8')) | |
| 45 | if (parsed && parsed.schemaVersion === 1 && parsed.apps && typeof parsed.apps === 'object') return parsed | |
| 46 | } catch (err) { | |
| 47 | if (err && err.code !== 'ENOENT') throw new Error(`could not read ${RELEASE_STATE_FILE}: ${err.message}`) | |
| 48 | } | |
| 49 | return { schemaVersion: 1, apps: {} } | |
| 50 | } | |
| 51 | ||
| 52 | export async function saveReleaseState (storagePath, state) { | |
| 53 | await mkdir(storagePath, { recursive: true }) | |
| 54 | const target = join(storagePath, RELEASE_STATE_FILE) | |
| 55 | const temporary = target + '.tmp' | |
| 56 | await writeFile(temporary, JSON.stringify(state, null, 2), { mode: 0o600 }) | |
| 57 | await rename(temporary, target) | |
| 58 | } | |
| 59 | ||
| 60 | export function publisherKeyPairForApp (state, appId) { | |
| 61 | if (!state || !state.apps || !appId) throw new Error('publisher state and appId are required') | |
| 62 | const current = state.apps[appId] || {} | |
| 63 | let seed | |
| 64 | if (typeof current.publisherSeed === 'string' && /^[0-9a-f]{64}$/i.test(current.publisherSeed)) { | |
| 65 | seed = b4a.from(current.publisherSeed, 'hex') | |
| 66 | } else { | |
| 67 | seed = b4a.alloc(32) | |
| 68 | sodium.randombytes_buf(seed) | |
| 69 | } | |
| 70 | const publicKey = b4a.alloc(32) | |
| 71 | const secretKey = b4a.alloc(64) | |
| 72 | sodium.crypto_sign_seed_keypair(publicKey, secretKey, seed) | |
| 73 | const publisherPubkey = b4a.toString(publicKey, 'hex') | |
| 74 | if (current.publisherPubkey && current.publisherPubkey.toLowerCase() !== publisherPubkey) { | |
| 75 | throw new Error(`publisher key state for ${appId} is inconsistent`) | |
| 76 | } | |
| 77 | state.apps[appId] = { | |
| 78 | ...current, | |
| 79 | publisherSeed: b4a.toString(seed, 'hex'), | |
| 80 | publisherPubkey | |
| 81 | } | |
| 82 | return { publicKey, secretKey } | |
| 83 | } | |
| 84 | ||
| 85 | export function createSignedSeedRequest ({ | |
| 86 | appKey, | |
| 87 | maxStorageBytes, | |
| 88 | blind = false, | |
| 89 | keyPair, | |
| 90 | issuedAt = Date.now(), | |
| 91 | requestNonce = null | |
| 92 | }) { | |
| 93 | const nonce = requestNonce || b4a.alloc(16) | |
| 94 | if (!requestNonce) sodium.randombytes_buf(nonce) | |
| 95 | const request = { | |
| 96 | appKey: b4a.from(appKey, 'hex'), | |
| 97 | discoveryKeys: [], | |
| 98 | replicationFactor: 3, | |
| 99 | maxStorageBytes, | |
| 100 | ttlSeconds: 30 * 24 * 3600, | |
| 101 | bountyRate: 0, | |
| 102 | revocable: true, | |
| 103 | unseedFreezeMs: 0, | |
| 104 | durability: 0, | |
| 105 | issuedAt, | |
| 106 | requestNonce: nonce, | |
| 107 | publisherPubkey: keyPair.publicKey | |
| 108 | } | |
| 109 | const signature = b4a.alloc(64) | |
| 110 | sodium.crypto_sign_detached(signature, serializeSeedRequestForReplaySigning(request), keyPair.secretKey) | |
| 111 | return { | |
| 112 | appKey, | |
| 113 | replicationFactor: request.replicationFactor, | |
| 114 | maxStorageBytes, | |
| 115 | ttlSeconds: request.ttlSeconds, | |
| 116 | bountyRate: request.bountyRate, | |
| 117 | revocable: request.revocable, | |
| 118 | unseedFreezeMs: request.unseedFreezeMs, | |
| 119 | durability: request.durability, | |
| 120 | issuedAt, | |
| 121 | requestNonce: b4a.toString(nonce, 'hex'), | |
| 122 | publisherPubkey: b4a.toString(keyPair.publicKey, 'hex'), | |
| 123 | publisherSignature: b4a.toString(signature, 'hex'), | |
| 124 | type: 'app', | |
| 125 | blind | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | function priorReleaseHistory (appState) { | |
| 130 | return Array.isArray(appState?.releases) | |
| 131 | ? appState.releases.filter(release => release && typeof release.driveKey === 'string') | |
| 132 | : [] | |
| 133 | } | |
| 134 | ||
| 135 | export function createPublisherRelease ({ | |
| 136 | appState = {}, | |
| 137 | appId, | |
| 138 | version, | |
| 139 | driveKey, | |
| 140 | previousDriveKey = null, | |
| 141 | storageBudgetBytes, | |
| 142 | rollbackWindow, | |
| 143 | treeHash, | |
| 144 | issuedAt = Date.now(), | |
| 145 | keyPair | |
| 146 | }) { | |
| 147 | const sequence = Number.isSafeInteger(appState.sequence) && appState.sequence > 0 | |
| 148 | ? appState.sequence + 1 | |
| 149 | : 1 | |
| 150 | const priorGeneration = Number.isSafeInteger(appState.generation) && appState.generation > 0 | |
| 151 | ? appState.generation | |
| 152 | : 1 | |
| 153 | const generation = previousDriveKey ? priorGeneration + 1 : priorGeneration | |
| 154 | const history = priorReleaseHistory(appState) | |
| 155 | const relevantHistory = history.slice(-Math.max(0, rollbackWindow - 1)) | |
| 156 | const rollbackDriveKeys = [] | |
| 157 | const seen = new Set([driveKey]) | |
| 158 | for (let i = relevantHistory.length - 1; i >= 0 && rollbackDriveKeys.length < rollbackWindow - 1; i--) { | |
| 159 | const key = relevantHistory[i].driveKey.toLowerCase() | |
| 160 | if (!/^[0-9a-f]{64}$/.test(key) || seen.has(key)) continue | |
| 161 | seen.add(key) | |
| 162 | rollbackDriveKeys.push(key) | |
| 163 | } | |
| 164 | if (previousDriveKey && rollbackWindow > 1 && !seen.has(previousDriveKey)) { | |
| 165 | rollbackDriveKeys.unshift(previousDriveKey) | |
| 166 | if (rollbackDriveKeys.length > rollbackWindow - 1) rollbackDriveKeys.pop() | |
| 167 | } | |
| 168 | ||
| 169 | const release = signAppRelease({ | |
| 170 | protocolVersion: 1, | |
| 171 | appId, | |
| 172 | version, | |
| 173 | sequence, | |
| 174 | generation, | |
| 175 | driveKey, | |
| 176 | previousDriveKey, | |
| 177 | rotationReason: previousDriveKey ? 'storage-budget' : null, | |
| 178 | storageBudgetBytes, | |
| 179 | rollbackWindow, | |
| 180 | rollbackDriveKeys, | |
| 181 | treeHash, | |
| 182 | issuedAt | |
| 183 | }, keyPair) | |
| 184 | ||
| 185 | const releases = [...history, { | |
| 186 | version, | |
| 187 | sequence, | |
| 188 | generation, | |
| 189 | driveKey, | |
| 190 | issuedAt | |
| 191 | }].slice(-rollbackWindow) | |
| 192 | ||
| 193 | return { | |
| 194 | release, | |
| 195 | appState: { | |
| 196 | ...appState, | |
| 197 | publisherPubkey: release.publisherPubkey, | |
| 198 | sequence, | |
| 199 | generation, | |
| 200 | currentDriveKey: driveKey, | |
| 201 | releases | |
| 202 | } | |
| 203 | } | |
| 204 | } | |
| 205 | ||
| 206 | export function driveLogicalBytes (drive) { | |
| 207 | const metadataBytes = Number(drive?.core?.byteLength) || 0 | |
| 208 | const blobBytes = Number(drive?.blobs?.core?.byteLength) || 0 | |
| 209 | return metadataBytes + blobBytes | |
| 210 | } | |
| 211 | ||
| 212 | export function estimateReleaseAppendBytes (plan) { | |
| 213 | const mutations = (plan?.writes?.length || 0) + (plan?.removed?.length || 0) | |
| 214 | const contentBytes = Number(plan?.contentBytes) || 0 | |
| 215 | return contentBytes + Math.max(4096, mutations * 4096) | |
| 216 | } | |
| 217 | ||
| 218 | export function shouldRotateReleaseDrive ({ driveBytes, plan, storageBudgetBytes }) { | |
| 219 | const projectedBytes = driveBytes + estimateReleaseAppendBytes(plan) + ROTATION_POINTER_RESERVE_BYTES | |
| 220 | return { | |
| 221 | rotate: projectedBytes > storageBudgetBytes, | |
| 222 | projectedBytes | |
| 223 | } | |
| 224 | } |
| … | … | @@ -17,6 +17,8 @@ |
| 17 | 17 | * --relays <urls> Comma-separated relay API URLs to seed on |
| 18 | 18 | * --storage <path> Corestore path (default: .publisher-storage) |
| 19 | 19 | * --key <hex> Explicit drive key (overrides appId lookup) |
| 20 | * --storage-budget <n> Rotate before the drive exceeds this size (default: 1GiB) | |
| 21 | * --rollback-window <n> Number of signed releases to retain (default: 3, max: 32) | |
| 20 | 22 | * --bootstrap <nodes> Comma-separated DHT bootstrap nodes (host:port) |
| 21 | 23 | * --api-key <key> Shared operator API key sent to every relay |
| 22 | 24 | * (fallback: HIVERELAY_API_KEY env var) |
| … | … | @@ -42,6 +44,22 @@ import sodium from 'sodium-universal' |
| 42 | 44 | import { readdir, readFile, writeFile, stat, mkdir, rename } from 'fs/promises' |
| 43 | 45 | import { join, relative, resolve } from 'path' |
| 44 | 46 | import { existsSync } from 'fs' |
| 47 | import { hashReleaseTree } from '../packages/core/core/release-lifecycle.js' | |
| 48 | import { planPublishedFiles, syncPublishedFiles } from './lib/publish-drive-sync.mjs' | |
| 49 | import { | |
| 50 | DEFAULT_RELEASE_ROLLBACK_WINDOW, | |
| 51 | DEFAULT_RELEASE_STORAGE_BUDGET, | |
| 52 | createSignedSeedRequest, | |
| 53 | createPublisherRelease, | |
| 54 | driveLogicalBytes, | |
| 55 | estimateReleaseAppendBytes, | |
| 56 | formatStorageBytes, | |
| 57 | loadReleaseState, | |
| 58 | parseStorageBytes, | |
| 59 | publisherKeyPairForApp, | |
| 60 | saveReleaseState, | |
| 61 | shouldRotateReleaseDrive | |
| 62 | } from './lib/release-publisher.mjs' | |
| 45 | 63 | |
| 46 | 64 | const RELAY_DISCOVERY_TOPIC = b4a.alloc(32) |
| 47 | 65 | sodium.crypto_generichash(RELAY_DISCOVERY_TOPIC, b4a.from('hiverelay-discovery-v1')) |
| … | … | @@ -63,6 +81,8 @@ function parseArgs (argv) { |
| 63 | 81 | version: '1.0.0', |
| 64 | 82 | relays: DEFAULT_RELAYS, |
| 65 | 83 | storage: '.publisher-storage', |
| 84 | storageBudgetBytes: DEFAULT_RELEASE_STORAGE_BUDGET, | |
| 85 | rollbackWindow: DEFAULT_RELEASE_ROLLBACK_WINDOW, | |
| 66 | 86 | key: null, |
| 67 | 87 | bootstrap: DEFAULT_BOOTSTRAP, |
| 68 | 88 | apiKey: process.env.HIVERELAY_API_KEY || null, |
| … | … | @@ -78,8 +98,26 @@ function parseArgs (argv) { |
| 78 | 98 | if (arg === '--id') { opts.id = args[++i]; continue } |
| 79 | 99 | if (arg === '--desc') { opts.description = args[++i]; continue } |
| 80 | 100 | if (arg === '--version') { opts.version = args[++i]; continue } |
| 81 | if (arg === '--relays') { opts.relays = args[++i].split(',').map(s => s.trim()); continue } | |
| 101 | if (arg === '--relays') { opts.relays = args[++i].split(',').map(s => s.trim()).filter(Boolean); continue } | |
| 82 | 102 | if (arg === '--storage') { opts.storage = args[++i]; continue } |
| 103 | if (arg === '--storage-budget') { | |
| 104 | const bytes = parseStorageBytes(args[++i]) | |
| 105 | if (bytes === null) { | |
| 106 | console.error('Error: --storage-budget must be a positive size such as 500MiB or 2GiB') | |
| 107 | process.exit(1) | |
| 108 | } | |
| 109 | opts.storageBudgetBytes = bytes | |
| 110 | continue | |
| 111 | } | |
| 112 | if (arg === '--rollback-window') { | |
| 113 | const count = Number(args[++i]) | |
| 114 | if (!Number.isInteger(count) || count < 1 || count > 32) { | |
| 115 | console.error('Error: --rollback-window must be an integer from 1 to 32') | |
| 116 | process.exit(1) | |
| 117 | } | |
| 118 | opts.rollbackWindow = count | |
| 119 | continue | |
| 120 | } | |
| 83 | 121 | if (arg === '--key') { opts.key = args[++i]; continue } |
| 84 | 122 | if (arg === '--bootstrap') { |
| 85 | 123 | try { |
| … | … | @@ -164,6 +202,13 @@ function deriveAppId (name) { |
| 164 | 202 | return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') |
| 165 | 203 | } |
| 166 | 204 | |
| 205 | function createReleaseDrive (store, driveOpts) { | |
| 206 | const suffix = b4a.alloc(8) | |
| 207 | sodium.randombytes_buf(suffix) | |
| 208 | const namespace = store.namespace('app-release-' + Date.now() + '-' + b4a.toString(suffix, 'hex')) | |
| 209 | return new Hyperdrive(namespace, null, driveOpts) | |
| 210 | } | |
| 211 | ||
| 167 | 212 | function parseBootstrapNodes (input) { |
| 168 | 213 | if (!input) return null |
| 169 | 214 | const nodes = String(input) |
| … | … | @@ -197,7 +242,9 @@ async function loadDriveMap (storagePath) { |
| 197 | 242 | async function saveDriveMap (storagePath, map) { |
| 198 | 243 | await mkdir(storagePath, { recursive: true }) |
| 199 | 244 | const mapPath = join(storagePath, 'app-drives.json') |
| 200 | await writeFile(mapPath, JSON.stringify(map, null, 2)) | |
| 245 | const temporary = mapPath + '.tmp' | |
| 246 | await writeFile(temporary, JSON.stringify(map, null, 2)) | |
| 247 | await rename(temporary, mapPath) | |
| 201 | 248 | } |
| 202 | 249 | |
| 203 | 250 | async function walkDir (dir, base) { |
| … | … | @@ -268,15 +315,27 @@ async function resolveFromRelay (relays, appId) { |
| 268 | 315 | return null |
| 269 | 316 | } |
| 270 | 317 | |
| 271 | async function seedOnRelay (relayUrl, appKey, appId, version, blind, apiKey) { | |
| 318 | async function seedOnRelay (relayUrl, appKey, appId, version, blind, apiKey, maxStorageBytes, keyPair) { | |
| 272 | 319 | try { |
| 273 | const body = { appKey } | |
| 274 | if (appId) body.appId = appId | |
| 275 | if (version) body.version = version | |
| 276 | if (blind) body.blind = true | |
| 320 | const publisherRequest = createSignedSeedRequest({ appKey, maxStorageBytes, blind, keyPair }) | |
| 321 | const body = apiKey | |
| 322 | ? { | |
| 323 | appKey, | |
| 324 | appId, | |
| 325 | version, | |
| 326 | blind, | |
| 327 | maxStorageBytes, | |
| 328 | opts: { | |
| 329 | publisherPubkey: publisherRequest.publisherPubkey, | |
| 330 | publisherSignature: publisherRequest.publisherSignature, | |
| 331 | seedSignatureProfile: 'replay-v1' | |
| 332 | } | |
| 333 | } | |
| 334 | : publisherRequest | |
| 277 | 335 | const headers = { 'Content-Type': 'application/json' } |
| 278 | 336 | if (apiKey) headers.Authorization = `Bearer ${apiKey}` |
| 279 | const res = await fetch(relayUrl + '/seed', { | |
| 337 | const route = apiKey ? '/seed' : '/api/v1/seed' | |
| 338 | const res = await fetch(relayUrl + route, { | |
| 280 | 339 | method: 'POST', |
| 281 | 340 | headers, |
| 282 | 341 | body: JSON.stringify(body) |
| … | … | @@ -313,6 +372,8 @@ async function run () { |
| 313 | 372 | console.error(' --version <version> App version (default: 1.0.0)') |
| 314 | 373 | console.error(' --storage <path> Publisher storage path') |
| 315 | 374 | console.error(' --key <hex> Explicit drive key') |
| 375 | console.error(' --storage-budget <n> Per-drive budget before signed rotation (default: 1GiB)') | |
| 376 | console.error(' --rollback-window <n> Signed releases to retain (default: 3, max: 32)') | |
| 316 | 377 | console.error(' --api-key <key> Shared operator API key (env: HIVERELAY_API_KEY)') |
| 317 | 378 | console.error(' --api-keys <map> Per-relay keys url=key,url=key (env: HIVERELAY_API_KEYS)') |
| 318 | 379 | console.error(' --blind Encrypt content (relay can\'t read, P2P only)') |
| … | … | @@ -328,6 +389,10 @@ async function run () { |
| 328 | 389 | } |
| 329 | 390 | |
| 330 | 391 | const appId = opts.id || (opts.name ? deriveAppId(opts.name) : null) |
| 392 | if (!appId) { | |
| 393 | console.error('Error: --name or --id is required for signed, bounded app releases') | |
| 394 | process.exit(1) | |
| 395 | } | |
| 331 | 396 | |
| 332 | 397 | console.log() |
| 333 | 398 | console.log('=== HiveRelay App Publisher ===') |
| … | … | @@ -337,6 +402,8 @@ async function run () { |
| 337 | 402 | console.log(' Relays: ', opts.relays.length) |
| 338 | 403 | if (opts.bootstrap) console.log(' Bootstrap:', opts.bootstrap.map(n => `${n.host}:${n.port}`).join(', ')) |
| 339 | 404 | if (appId) console.log(' App ID: ', appId) |
| 405 | console.log(' Budget: ', formatStorageBytes(opts.storageBudgetBytes), 'per drive') | |
| 406 | console.log(' Rollback: ', opts.rollbackWindow, 'release' + (opts.rollbackWindow === 1 ? '' : 's')) | |
| 340 | 407 | if (opts.version !== '1.0.0') console.log(' Version: ', opts.version) |
| 341 | 408 | if (opts.blind) console.log(' Mode: BLIND (encrypted, P2P only)') |
| 342 | 409 | console.log() |
| … | … | @@ -353,8 +420,11 @@ async function run () { |
| 353 | 420 | const store = new Corestore(opts.storage) |
| 354 | 421 | await store.ready() |
| 355 | 422 | |
| 356 | const swarm = opts.bootstrap ? new Hyperswarm({ bootstrap: opts.bootstrap }) : new Hyperswarm() | |
| 357 | swarm.on('connection', (conn) => store.replicate(conn)) | |
| 423 | const networkEnabled = opts.stay || opts.relays.length > 0 | |
| 424 | const swarm = networkEnabled | |
| 425 | ? (opts.bootstrap ? new Hyperswarm({ bootstrap: opts.bootstrap }) : new Hyperswarm()) | |
| 426 | : null | |
| 427 | if (swarm) swarm.on('connection', (conn) => store.replicate(conn)) | |
| 358 | 428 | |
| 359 | 429 | // For blind mode: use explicit key, load saved key, or generate new one |
| 360 | 430 | let encryptionKey = null |
| … | … | @@ -373,6 +443,14 @@ async function run () { |
| 373 | 443 | // Hyperdrive constructor options (with optional encryption) |
| 374 | 444 | const driveOpts = encryptionKey ? { encryptionKey } : {} |
| 375 | 445 | |
| 446 | // One stable Ed25519 identity signs both seed requests and release-key | |
| 447 | // transitions. Persist it before touching a drive so a crash cannot publish | |
| 448 | // a release whose signing authority is immediately lost. | |
| 449 | const releaseState = await loadReleaseState(opts.storage) | |
| 450 | const releaseKeyPair = publisherKeyPairForApp(releaseState, appId) | |
| 451 | await saveReleaseState(opts.storage, releaseState) | |
| 452 | let appReleaseState = releaseState.apps[appId] | |
| 453 | ||
| 376 | 454 | // Resolve drive key: explicit --key > saved mapping > new drive |
| 377 | 455 | let drive |
| 378 | 456 | let isUpdate = false |
| … | … | @@ -385,9 +463,10 @@ async function run () { |
| 385 | 463 | } else if (appId) { |
| 386 | 464 | // Check saved appId → key mapping |
| 387 | 465 | const driveMap = await loadDriveMap(opts.storage) |
| 388 | if (driveMap[appId]) { | |
| 389 | console.log(' Found existing drive for "' + appId + '": ' + driveMap[appId].slice(0, 16) + '...') | |
| 390 | drive = new Hyperdrive(store, Buffer.from(driveMap[appId], 'hex'), driveOpts) | |
| 466 | const savedDriveKey = appReleaseState.currentDriveKey || driveMap[appId] | |
| 467 | if (savedDriveKey) { | |
| 468 | console.log(' Found existing drive for "' + appId + '": ' + savedDriveKey.slice(0, 16) + '...') | |
| 469 | drive = new Hyperdrive(store, Buffer.from(savedDriveKey, 'hex'), driveOpts) | |
| 391 | 470 | isUpdate = true |
| 392 | 471 | } else { |
| 393 | 472 | // No local mapping — check relay registry (handles publisher storage loss) |
| … | … | @@ -414,26 +493,24 @@ async function run () { |
| 414 | 493 | |
| 415 | 494 | if (!drive) { |
| 416 | 495 | console.log(' Creating new Hyperdrive...') |
| 417 | drive = new Hyperdrive(store, null, driveOpts) | |
| 496 | drive = createReleaseDrive(store, driveOpts) | |
| 418 | 497 | } |
| 419 | 498 | |
| 420 | 499 | await drive.ready() |
| 421 | 500 | |
| 422 | const driveKey = b4a.toString(drive.key, 'hex') | |
| 423 | console.log(' Drive key:', driveKey) | |
| 424 | console.log(' Mode: ', isUpdate ? 'UPDATE (same key, new version)' : 'NEW DRIVE') | |
| 425 | console.log() | |
| 501 | if (!drive.writable) { | |
| 502 | throw new Error('publisher storage does not hold the secret key for this drive; restore the original publisher storage or publish a new app id') | |
| 503 | } | |
| 426 | 504 | |
| 427 | // Save the appId → key mapping for future publishes | |
| 428 | if (appId) { | |
| 429 | const driveMap = await loadDriveMap(opts.storage) | |
| 430 | driveMap[appId] = driveKey | |
| 431 | await saveDriveMap(opts.storage, driveMap) | |
| 505 | let driveKey = b4a.toString(drive.key, 'hex') | |
| 506 | if (appReleaseState.currentDriveKey && appReleaseState.currentDriveKey !== driveKey) { | |
| 507 | throw new Error('explicit drive key does not match signed release state; automatic storage-budget rotation is the only supported key transition') | |
| 432 | 508 | } |
| 433 | 509 | |
| 434 | // Build manifest.json | |
| 435 | const manifest = { | |
| 436 | id: appId || driveKey.slice(0, 12), | |
| 510 | // Build the user-facing portion of manifest.json once. The signed release | |
| 511 | // envelope is attached only after the final (possibly rotated) key is known. | |
| 512 | const manifestBase = { | |
| 513 | id: appId, | |
| 437 | 514 | name: opts.name || 'Unknown App', |
| 438 | 515 | description: opts.description || (opts.name ? opts.name + ' — published via HiveRelay' : ''), |
| 439 | 516 | version: opts.version, |
| … | … | @@ -449,44 +526,141 @@ async function run () { |
| 449 | 526 | // Merge user's manifest with our fields (user fields take priority) |
| 450 | 527 | try { |
| 451 | 528 | const userManifest = JSON.parse(files.find(f => f.path === '/manifest.json').content.toString()) |
| 452 | Object.assign(manifest, userManifest) | |
| 453 | // But always ensure id is set | |
| 454 | if (!manifest.id) manifest.id = appId || driveKey.slice(0, 12) | |
| 529 | Object.assign(manifestBase, userManifest) | |
| 530 | // The CLI app id is the stable publisher identity and cannot drift in a | |
| 531 | // user-supplied manifest between releases. | |
| 532 | manifestBase.id = appId | |
| 455 | 533 | console.log(' Using user-provided manifest.json (merged with publisher metadata)') |
| 456 | 534 | } catch (_) { |
| 457 | 535 | console.log(' Warning: could not parse user manifest.json, using generated one') |
| 458 | 536 | } |
| 459 | 537 | } |
| 460 | 538 | |
| 461 | await drive.put('/manifest.json', b4a.from(JSON.stringify(manifest, null, 2))) | |
| 462 | console.log(' Wrote manifest.json (id: ' + manifest.id + ', version: ' + manifest.version + ')') | |
| 463 | ||
| 464 | // Write all files | |
| 465 | console.log(' Writing files to Hyperdrive...') | |
| 466 | let written = 0 | |
| 467 | for (const file of files) { | |
| 468 | if (file.path === '/manifest.json') { written++; continue } // already written | |
| 469 | await drive.put(file.path, file.content) | |
| 470 | written++ | |
| 471 | if (written % 10 === 0 || written === files.length) { | |
| 472 | process.stdout.write('\r Progress: ' + written + '/' + files.length) | |
| 539 | const contentFiles = files.filter(file => file.path !== '/manifest.json') | |
| 540 | const treeHash = hashReleaseTree(contentFiles) | |
| 541 | const buildRelease = (key, previousDriveKey = null) => createPublisherRelease({ | |
| 542 | appState: appReleaseState, | |
| 543 | appId, | |
| 544 | version: String(manifestBase.version), | |
| 545 | driveKey: key, | |
| 546 | previousDriveKey, | |
| 547 | storageBudgetBytes: opts.storageBudgetBytes, | |
| 548 | rollbackWindow: opts.rollbackWindow, | |
| 549 | treeHash, | |
| 550 | keyPair: releaseKeyPair | |
| 551 | }) | |
| 552 | const buildFiles = (signedRelease) => { | |
| 553 | const existingHiveRelay = manifestBase.hiverelay && typeof manifestBase.hiverelay === 'object' && !Array.isArray(manifestBase.hiverelay) | |
| 554 | ? manifestBase.hiverelay | |
| 555 | : {} | |
| 556 | const manifest = { | |
| 557 | ...manifestBase, | |
| 558 | hiverelay: { ...existingHiveRelay, release: signedRelease } | |
| 559 | } | |
| 560 | return { | |
| 561 | manifest, | |
| 562 | releaseFiles: [ | |
| 563 | ...contentFiles, | |
| 564 | { path: '/manifest.json', content: b4a.from(JSON.stringify(manifest, null, 2)) } | |
| 565 | ] | |
| 566 | } | |
| 567 | } | |
| 568 | ||
| 569 | let previousDrive = null | |
| 570 | let previousDriveKey = null | |
| 571 | let builtRelease = buildRelease(driveKey) | |
| 572 | let builtFiles = buildFiles(builtRelease.release) | |
| 573 | let publishPlan = await planPublishedFiles(drive, builtFiles.releaseFiles) | |
| 574 | const budgetPlan = shouldRotateReleaseDrive({ | |
| 575 | driveBytes: driveLogicalBytes(drive), | |
| 576 | plan: publishPlan, | |
| 577 | storageBudgetBytes: opts.storageBudgetBytes | |
| 578 | }) | |
| 579 | ||
| 580 | if (budgetPlan.rotate) { | |
| 581 | if (!isUpdate || drive.version === 0) { | |
| 582 | const required = driveLogicalBytes(drive) + estimateReleaseAppendBytes(publishPlan) | |
| 583 | throw new Error(`release needs approximately ${formatStorageBytes(required)}, above the ${formatStorageBytes(opts.storageBudgetBytes)} drive budget`) | |
| 584 | } | |
| 585 | previousDrive = drive | |
| 586 | previousDriveKey = driveKey | |
| 587 | console.log(' Storage budget reached at projected', formatStorageBytes(budgetPlan.projectedBytes)) | |
| 588 | console.log(' Rotating to a new publisher-signed release key...') | |
| 589 | drive = createReleaseDrive(store, driveOpts) | |
| 590 | await drive.ready() | |
| 591 | driveKey = b4a.toString(drive.key, 'hex') | |
| 592 | builtRelease = buildRelease(driveKey, previousDriveKey) | |
| 593 | builtFiles = buildFiles(builtRelease.release) | |
| 594 | publishPlan = await planPublishedFiles(drive, builtFiles.releaseFiles) | |
| 595 | const freshBudgetPlan = shouldRotateReleaseDrive({ | |
| 596 | driveBytes: driveLogicalBytes(drive), | |
| 597 | plan: publishPlan, | |
| 598 | storageBudgetBytes: opts.storageBudgetBytes | |
| 599 | }) | |
| 600 | if (freshBudgetPlan.rotate) { | |
| 601 | throw new Error(`release plus rotation reserve needs ${formatStorageBytes(freshBudgetPlan.projectedBytes)}, above the ${formatStorageBytes(opts.storageBudgetBytes)} drive budget`) | |
| 473 | 602 | } |
| 474 | 603 | } |
| 475 | console.log('\n All files written. Drive version:', drive.version) | |
| 604 | ||
| 605 | const manifest = builtFiles.manifest | |
| 606 | console.log(' Drive key:', driveKey) | |
| 607 | console.log(' Mode: ', previousDrive ? 'ROTATED (signed storage-budget transition)' : (isUpdate ? 'UPDATE (same key, delta)' : 'NEW DRIVE')) | |
| 608 | console.log(' Release: ', '#' + builtRelease.release.sequence, 'generation', builtRelease.release.generation) | |
| 476 | 609 | console.log() |
| 477 | 610 | |
| 611 | // Mirror the release tree instead of blindly appending every file. This | |
| 612 | // skips byte-identical files, block-deduplicates changed files, and removes | |
| 613 | // paths that disappeared from the build. | |
| 614 | console.log(' Synchronizing release files to Hyperdrive...') | |
| 615 | const sync = await syncPublishedFiles(drive, builtFiles.releaseFiles, { plan: publishPlan }) | |
| 616 | console.log(' Added:', sync.added, 'Changed:', sync.changed, 'Removed:', sync.removed, 'Unchanged:', sync.unchanged) | |
| 617 | console.log(' Drive version:', drive.version) | |
| 618 | console.log() | |
| 619 | ||
| 620 | if (previousDrive) { | |
| 621 | await previousDrive.put( | |
| 622 | '/.hiverelay/rotation.json', | |
| 623 | b4a.from(JSON.stringify(builtRelease.release, null, 2)) | |
| 624 | ) | |
| 625 | console.log(' Signed rotation pointer written to predecessor:', previousDriveKey) | |
| 626 | console.log() | |
| 627 | } | |
| 628 | ||
| 629 | // Commit publisher state only after the content and predecessor pointer are | |
| 630 | // durable. A failed relay request can be retried without re-signing history. | |
| 631 | appReleaseState = builtRelease.appState | |
| 632 | releaseState.apps[appId] = appReleaseState | |
| 633 | await saveReleaseState(opts.storage, releaseState) | |
| 634 | const driveMap = await loadDriveMap(opts.storage) | |
| 635 | driveMap[appId] = driveKey | |
| 636 | await saveDriveMap(opts.storage, driveMap) | |
| 637 | ||
| 478 | 638 | // Join DHT |
| 479 | console.log(' Joining DHT...') | |
| 480 | swarm.join(drive.discoveryKey, { server: true, client: true }) | |
| 481 | swarm.join(RELAY_DISCOVERY_TOPIC, { server: true, client: false }) | |
| 482 | await swarm.flush() | |
| 483 | console.log(' Announced on DHT.') | |
| 639 | if (swarm) { | |
| 640 | console.log(' Joining DHT...') | |
| 641 | swarm.join(drive.discoveryKey, { server: true, client: true }) | |
| 642 | if (previousDrive) swarm.join(previousDrive.discoveryKey, { server: true, client: true }) | |
| 643 | swarm.join(RELAY_DISCOVERY_TOPIC, { server: true, client: false }) | |
| 644 | await swarm.flush() | |
| 645 | console.log(' Announced on DHT.') | |
| 646 | } else { | |
| 647 | console.log(' Offline publish: DHT announcement skipped (no relays, --no-stay).') | |
| 648 | } | |
| 484 | 649 | console.log() |
| 485 | 650 | |
| 486 | 651 | // Seed on relays (pass appId + version for deduplication) |
| 487 | 652 | console.log(' Seeding on relays...') |
| 488 | 653 | const seedResults = await Promise.all( |
| 489 | opts.relays.map(url => seedOnRelay(url, driveKey, manifest.id, manifest.version, opts.blind, resolveApiKey(url, opts))) | |
| 654 | opts.relays.map(url => seedOnRelay( | |
| 655 | url, | |
| 656 | driveKey, | |
| 657 | manifest.id, | |
| 658 | manifest.version, | |
| 659 | opts.blind, | |
| 660 | resolveApiKey(url, opts), | |
| 661 | opts.storageBudgetBytes, | |
| 662 | releaseKeyPair | |
| 663 | )) | |
| 490 | 664 | ) |
| 491 | 665 | |
| 492 | 666 | let seeded = 0 |
| … | … | @@ -507,7 +681,7 @@ async function run () { |
| 507 | 681 | console.error(' ┌─────────────────────────────────────────────────────────────────┐') |
| 508 | 682 | console.error(' │ AUTH FAILURE: ' + authFailures.length + '/' + opts.relays.length + ' relay(s) returned 401 Unauthorized. │') |
| 509 | 683 | console.error(' └─────────────────────────────────────────────────────────────────┘') |
| 510 | console.error(' /seed requires each relay\'s operator API key. Refused by:') | |
| 684 | console.error(' The relay rejected both publisher ingress and operator authorization. Refused by:') | |
| 511 | 685 | for (const f of authFailures) { |
| 512 | 686 | console.error(' ' + f.url + (f.keySent ? ' (a key WAS sent — wrong key for this relay?)' : ' (no key sent)')) |
| 513 | 687 | } |
| … | … | @@ -559,13 +733,15 @@ async function run () { |
| 559 | 733 | |
| 560 | 734 | // Monitor replication |
| 561 | 735 | let peerCount = 0 |
| 562 | swarm.on('connection', () => { | |
| 563 | peerCount++ | |
| 564 | console.log(' [' + new Date().toISOString().slice(11, 19) + '] Peer connected (total: ' + peerCount + ')') | |
| 565 | }) | |
| 566 | swarm.on('connection', (conn) => { | |
| 567 | conn.on('close', () => { peerCount-- }) | |
| 568 | }) | |
| 736 | if (swarm) { | |
| 737 | swarm.on('connection', () => { | |
| 738 | peerCount++ | |
| 739 | console.log(' [' + new Date().toISOString().slice(11, 19) + '] Peer connected (total: ' + peerCount + ')') | |
| 740 | }) | |
| 741 | swarm.on('connection', (conn) => { | |
| 742 | conn.on('close', () => { peerCount-- }) | |
| 743 | }) | |
| 744 | } | |
| 569 | 745 | |
| 570 | 746 | if (opts.stay) { |
| 571 | 747 | console.log(' Publisher staying online for replication. Ctrl+C to exit.') |
| … | … | @@ -584,12 +760,14 @@ async function run () { |
| 584 | 760 | } else { |
| 585 | 761 | console.log(' Waiting ' + opts.holdSeconds + 's for initial replication...') |
| 586 | 762 | await new Promise(resolve => setTimeout(resolve, opts.holdSeconds * 1000)) |
| 587 | await swarm.destroy() | |
| 763 | if (swarm) await swarm.destroy() | |
| 588 | 764 | await store.close() |
| 589 | 765 | } |
| 590 | 766 | } |
| 591 | 767 | |
| 592 | run().catch(err => { | |
| 768 | try { | |
| 769 | await run() | |
| 770 | } catch (err) { | |
| 593 | 771 | console.error('Fatal:', err) |
| 594 | 772 | process.exit(1) |
| 595 | }) | |
| 773 | } |
| … | … | @@ -0,0 +1,68 @@ |
| 1 | import test from 'brittle' | |
| 2 | import b4a from 'b4a' | |
| 3 | import Corestore from 'corestore' | |
| 4 | import Hyperdrive from 'hyperdrive' | |
| 5 | import { spawn } from 'node:child_process' | |
| 6 | import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' | |
| 7 | import { tmpdir } from 'node:os' | |
| 8 | import { join, resolve } from 'node:path' | |
| 9 | import { verifyAppRelease } from '../../packages/core/core/release-lifecycle.js' | |
| 10 | ||
| 11 | function publish (directory, storage, version) { | |
| 12 | return new Promise((_resolve, reject) => { | |
| 13 | const child = spawn(process.execPath, [ | |
| 14 | resolve('scripts/publish-app.js'), | |
| 15 | directory, | |
| 16 | '--id', 'rotation-e2e', | |
| 17 | '--name', 'Rotation E2E', | |
| 18 | '--version', version, | |
| 19 | '--storage', storage, | |
| 20 | '--storage-budget', '128KiB', | |
| 21 | '--rollback-window', '3', | |
| 22 | '--relays', ',', | |
| 23 | '--no-stay', | |
| 24 | '--hold-seconds', '0' | |
| 25 | ], { cwd: resolve('.'), stdio: ['ignore', 'pipe', 'pipe'] }) | |
| 26 | let output = '' | |
| 27 | child.stdout.on('data', chunk => { output += chunk }) | |
| 28 | child.stderr.on('data', chunk => { output += chunk }) | |
| 29 | child.once('error', reject) | |
| 30 | child.once('exit', code => { | |
| 31 | if (code === 0) _resolve(output) | |
| 32 | else reject(new Error(`publisher exited ${code}:\n${output}`)) | |
| 33 | }) | |
| 34 | }) | |
| 35 | } | |
| 36 | ||
| 37 | test('publisher rotates at its budget and cross-writes a signed predecessor pointer', async (t) => { | |
| 38 | const root = await mkdtemp(join(tmpdir(), 'hiverelay-release-rotation-')) | |
| 39 | const app = join(root, 'app') | |
| 40 | const storage = join(root, 'publisher') | |
| 41 | await mkdir(app) | |
| 42 | t.teardown(() => rm(root, { recursive: true, force: true })) | |
| 43 | ||
| 44 | await writeFile(join(app, 'index.html'), b4a.alloc(40 * 1024, 0x61)) | |
| 45 | const firstOutput = await publish(app, storage, '1.0.0') | |
| 46 | const firstMap = JSON.parse(await readFile(join(storage, 'app-drives.json'), 'utf8')) | |
| 47 | const firstKey = firstMap['rotation-e2e'] | |
| 48 | t.ok(firstOutput.includes('NEW DRIVE')) | |
| 49 | ||
| 50 | await writeFile(join(app, 'index.html'), b4a.alloc(40 * 1024, 0x62)) | |
| 51 | const secondOutput = await publish(app, storage, '2.0.0') | |
| 52 | const secondMap = JSON.parse(await readFile(join(storage, 'app-drives.json'), 'utf8')) | |
| 53 | const secondKey = secondMap['rotation-e2e'] | |
| 54 | t.not(secondKey, firstKey) | |
| 55 | if (secondKey === firstKey) return | |
| 56 | t.ok(secondOutput.includes('ROTATED (signed storage-budget transition)')) | |
| 57 | ||
| 58 | const store = new Corestore(storage) | |
| 59 | const current = new Hyperdrive(store, b4a.from(secondKey, 'hex')) | |
| 60 | const predecessor = new Hyperdrive(store, b4a.from(firstKey, 'hex')) | |
| 61 | await Promise.all([current.ready(), predecessor.ready()]) | |
| 62 | const manifest = JSON.parse(b4a.toString(await current.get('/manifest.json', { wait: false }))) | |
| 63 | const pointer = JSON.parse(b4a.toString(await predecessor.get('/.hiverelay/rotation.json', { wait: false }))) | |
| 64 | t.ok(verifyAppRelease(manifest.hiverelay.release).ok) | |
| 65 | t.is(manifest.hiverelay.release.previousDriveKey, firstKey) | |
| 66 | t.alike(pointer, manifest.hiverelay.release) | |
| 67 | await store.close() | |
| 68 | }) |
| … | … | @@ -0,0 +1,245 @@ |
| 1 | import test from 'brittle' | |
| 2 | import b4a from 'b4a' | |
| 3 | import sodium from 'sodium-universal' | |
| 4 | import { mkdtemp, rm } from 'node:fs/promises' | |
| 5 | import { tmpdir } from 'node:os' | |
| 6 | import { join } from 'node:path' | |
| 7 | import { AppRegistry } from '../../packages/core/core/app-registry.js' | |
| 8 | import { AppLifecycle } from '../../packages/core/core/relay-node/app-lifecycle.js' | |
| 9 | import { hashReleaseTree, signAppRelease } from '../../packages/core/core/release-lifecycle.js' | |
| 10 | ||
| 11 | const OLD = 'a'.repeat(64) | |
| 12 | const NEW = 'b'.repeat(64) | |
| 13 | const WRONG = 'c'.repeat(64) | |
| 14 | const BUDGET = 1024 * 1024 | |
| 15 | const CONTENT = b4a.from('verified release content') | |
| 16 | const TREE_HASH = hashReleaseTree([{ path: '/index.html', content: CONTENT }]) | |
| 17 | ||
| 18 | function keyPair () { | |
| 19 | const publicKey = b4a.alloc(32) | |
| 20 | const secretKey = b4a.alloc(64) | |
| 21 | sodium.crypto_sign_seed_keypair(publicKey, secretKey, b4a.alloc(32, 5)) | |
| 22 | return { publicKey, secretKey } | |
| 23 | } | |
| 24 | ||
| 25 | function signedRelease (pair, overrides = {}) { | |
| 26 | return signAppRelease({ | |
| 27 | protocolVersion: 1, | |
| 28 | appId: 'demo', | |
| 29 | version: '2.0.0', | |
| 30 | sequence: 2, | |
| 31 | generation: 2, | |
| 32 | driveKey: NEW, | |
| 33 | previousDriveKey: OLD, | |
| 34 | rotationReason: 'storage-budget', | |
| 35 | storageBudgetBytes: BUDGET, | |
| 36 | rollbackWindow: 3, | |
| 37 | rollbackDriveKeys: [OLD], | |
| 38 | treeHash: TREE_HASH, | |
| 39 | issuedAt: Date.now(), | |
| 40 | ...overrides | |
| 41 | }, pair) | |
| 42 | } | |
| 43 | ||
| 44 | function predecessorRelease (pair) { | |
| 45 | return signAppRelease({ | |
| 46 | protocolVersion: 1, | |
| 47 | appId: 'demo', | |
| 48 | version: '1.0.0', | |
| 49 | sequence: 1, | |
| 50 | generation: 1, | |
| 51 | driveKey: OLD, | |
| 52 | previousDriveKey: null, | |
| 53 | rotationReason: null, | |
| 54 | storageBudgetBytes: BUDGET, | |
| 55 | rollbackWindow: 3, | |
| 56 | rollbackDriveKeys: [], | |
| 57 | treeHash: TREE_HASH, | |
| 58 | issuedAt: Date.now() | |
| 59 | }, pair) | |
| 60 | } | |
| 61 | ||
| 62 | function manifestDrive (release, content = CONTENT) { | |
| 63 | const manifest = b4a.from(JSON.stringify({ | |
| 64 | id: 'demo', | |
| 65 | name: 'Demo', | |
| 66 | version: release.version, | |
| 67 | hiverelay: { release } | |
| 68 | })) | |
| 69 | return { | |
| 70 | async get (path) { | |
| 71 | if (path === '/manifest.json') return manifest | |
| 72 | if (path === '/index.html') return content | |
| 73 | return null | |
| 74 | }, | |
| 75 | async * list () { | |
| 76 | yield { key: '/index.html' } | |
| 77 | yield { key: '/manifest.json' } | |
| 78 | } | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | function harness (release, oldRelease = null) { | |
| 83 | const publisher = release.publisherPubkey | |
| 84 | const registry = new AppRegistry(null) | |
| 85 | registry.set(OLD, { | |
| 86 | type: 'app', | |
| 87 | appId: 'demo', | |
| 88 | version: '1.0.0', | |
| 89 | publisherPubkey: publisher, | |
| 90 | maxStorage: BUDGET, | |
| 91 | release: oldRelease | |
| 92 | }, { persist: false }) | |
| 93 | registry.set(NEW, { | |
| 94 | type: 'app', | |
| 95 | appId: 'demo', | |
| 96 | version: '2.0.0', | |
| 97 | publisherPubkey: publisher, | |
| 98 | maxStorage: BUDGET | |
| 99 | }, { persist: false }) | |
| 100 | registry.persistEntry = async () => true | |
| 101 | const retained = [] | |
| 102 | const unseeded = [] | |
| 103 | const lifecycle = new AppLifecycle({ | |
| 104 | appRegistry: registry, | |
| 105 | eviction: { | |
| 106 | async reclaimSuperseded (opts) { retained.push(opts) } | |
| 107 | } | |
| 108 | }) | |
| 109 | lifecycle.unseedApp = async key => { unseeded.push(key) } | |
| 110 | return { lifecycle, registry, retained, unseeded } | |
| 111 | } | |
| 112 | ||
| 113 | test('relay accepts a signed predecessor-to-successor rotation and retains its rollback key', async (t) => { | |
| 114 | const release = signedRelease(keyPair()) | |
| 115 | const { lifecycle, registry, retained, unseeded } = harness(release) | |
| 116 | ||
| 117 | await lifecycle._indexAppManifest(NEW, manifestDrive(release)) | |
| 118 | ||
| 119 | t.is(registry.get(NEW).release.signature, release.signature) | |
| 120 | t.is(registry.get(NEW).publisherPubkey, release.publisherPubkey) | |
| 121 | t.alike(retained, [{ | |
| 122 | dryRun: false, | |
| 123 | appId: 'demo', | |
| 124 | publisherPubkey: release.publisherPubkey, | |
| 125 | retainKeys: [OLD] | |
| 126 | }]) | |
| 127 | t.alike(unseeded, [], 'predecessor stays seeded inside the signed rollback window') | |
| 128 | }) | |
| 129 | ||
| 130 | test('relay rejects a signed rotation that does not name its current predecessor', async (t) => { | |
| 131 | const release = signedRelease(keyPair(), { | |
| 132 | previousDriveKey: WRONG, | |
| 133 | rollbackDriveKeys: [WRONG] | |
| 134 | }) | |
| 135 | const { lifecycle, registry, retained, unseeded } = harness(release) | |
| 136 | const rejected = [] | |
| 137 | lifecycle.on('app-release-rejected', event => rejected.push(event)) | |
| 138 | ||
| 139 | await lifecycle._indexAppManifest(NEW, manifestDrive(release)) | |
| 140 | ||
| 141 | t.alike(unseeded, [NEW]) | |
| 142 | t.is(retained.length, 0) | |
| 143 | t.absent(registry.get(NEW).release, 'unverified transition is never persisted as release authority') | |
| 144 | t.ok(rejected[0].reason.includes('current predecessor')) | |
| 145 | }) | |
| 146 | ||
| 147 | test('same-drive signed releases advance without re-declaring the old predecessor', async (t) => { | |
| 148 | const pair = keyPair() | |
| 149 | const prior = signedRelease(pair) | |
| 150 | const next = signedRelease(pair, { | |
| 151 | version: '3.0.0', | |
| 152 | sequence: 3, | |
| 153 | driveKey: NEW, | |
| 154 | previousDriveKey: null, | |
| 155 | rotationReason: null, | |
| 156 | rollbackDriveKeys: [OLD], | |
| 157 | treeHash: TREE_HASH | |
| 158 | }) | |
| 159 | const { lifecycle, registry, retained, unseeded } = harness(next) | |
| 160 | registry.update(NEW, { release: prior }) | |
| 161 | ||
| 162 | await lifecycle._indexAppManifest(NEW, manifestDrive(next)) | |
| 163 | ||
| 164 | t.is(registry.get(NEW).release.sequence, 3) | |
| 165 | t.alike(retained, [{ | |
| 166 | dryRun: false, | |
| 167 | appId: 'demo', | |
| 168 | publisherPubkey: next.publisherPubkey, | |
| 169 | retainKeys: [OLD] | |
| 170 | }]) | |
| 171 | t.alike(unseeded, []) | |
| 172 | }) | |
| 173 | ||
| 174 | test('a rollback-protected predecessor can re-index its rotation pointer without being unseeded', async (t) => { | |
| 175 | const pair = keyPair() | |
| 176 | const prior = predecessorRelease(pair) | |
| 177 | const release = signedRelease(pair) | |
| 178 | const { lifecycle, registry, unseeded } = harness(release, prior) | |
| 179 | ||
| 180 | await lifecycle._indexAppManifest(NEW, manifestDrive(release)) | |
| 181 | await lifecycle._indexAppManifest(OLD, manifestDrive(prior)) | |
| 182 | ||
| 183 | t.alike(unseeded, []) | |
| 184 | t.is(registry.byAppId.get('demo'), NEW, 'new release remains the canonical lookup hint') | |
| 185 | t.ok(registry.has(OLD), 'rollback predecessor remains available') | |
| 186 | }) | |
| 187 | ||
| 188 | test('signed release sequence, not semver, orders an established release chain', async (t) => { | |
| 189 | const pair = keyPair() | |
| 190 | const prior = predecessorRelease(pair) | |
| 191 | const release = signedRelease(pair, { version: '0.5.0' }) | |
| 192 | const { lifecycle, registry, unseeded } = harness(release, prior) | |
| 193 | ||
| 194 | await lifecycle._indexAppManifest(NEW, manifestDrive(release)) | |
| 195 | ||
| 196 | t.alike(unseeded, []) | |
| 197 | t.is(registry.get(NEW).release.sequence, 2) | |
| 198 | t.is(registry.byAppId.get('demo'), NEW) | |
| 199 | t.is(registry.catalog()[0].appKey, NEW) | |
| 200 | t.is(registry.catalogForBroadcast()[0].appKey, NEW) | |
| 201 | }) | |
| 202 | ||
| 203 | test('relay rejects a signed manifest whose tree hash does not match the pinned drive', async (t) => { | |
| 204 | const release = signedRelease(keyPair()) | |
| 205 | const { lifecycle, registry, retained, unseeded } = harness(release) | |
| 206 | ||
| 207 | await lifecycle._indexAppManifest(NEW, manifestDrive(release, b4a.from('tampered'))) | |
| 208 | ||
| 209 | t.alike(unseeded, [NEW]) | |
| 210 | t.is(retained.length, 0) | |
| 211 | t.absent(registry.get(NEW).release) | |
| 212 | }) | |
| 213 | ||
| 214 | test('relay never reclaims a predecessor before signed release persistence succeeds', async (t) => { | |
| 215 | const release = signedRelease(keyPair()) | |
| 216 | const { lifecycle, registry, retained, unseeded } = harness(release) | |
| 217 | registry.persistEntry = async () => { throw new Error('disk full') } | |
| 218 | ||
| 219 | await lifecycle._indexAppManifest(NEW, manifestDrive(release)) | |
| 220 | ||
| 221 | t.is(retained.length, 0) | |
| 222 | t.alike(unseeded, [NEW]) | |
| 223 | }) | |
| 224 | ||
| 225 | test('verified release authority survives registry persistence and restart', async (t) => { | |
| 226 | const release = signedRelease(keyPair()) | |
| 227 | const dir = await mkdtemp(join(tmpdir(), 'hiverelay-release-registry-')) | |
| 228 | t.teardown(() => rm(dir, { recursive: true, force: true })) | |
| 229 | ||
| 230 | const first = new AppRegistry(dir) | |
| 231 | first.set(NEW, { | |
| 232 | type: 'app', | |
| 233 | appId: 'demo', | |
| 234 | version: release.version, | |
| 235 | publisherPubkey: release.publisherPubkey, | |
| 236 | maxStorage: BUDGET, | |
| 237 | release | |
| 238 | }) | |
| 239 | await first.flush() | |
| 240 | ||
| 241 | const restarted = new AppRegistry(dir) | |
| 242 | const reseed = await restarted.load() | |
| 243 | t.is(restarted.get(NEW).release.signature, release.signature) | |
| 244 | t.is(reseed[0].release.signature, release.signature) | |
| 245 | }) |
| … | … | @@ -189,6 +189,37 @@ test('audit-owned diff accepts known audit slice paths', async (t) => { |
| 189 | 189 | .owners.some(owner => owner.id === 'outboxlog-blind-recipient-trust-root-helper')) |
| 190 | 190 | }) |
| 191 | 191 | |
| 192 | test('audit-owned diff assigns the Hyperdrive release lifecycle surface', (t) => { | |
| 193 | const paths = [ | |
| 194 | 'docs/APP-RELEASE-LIFECYCLE.md', | |
| 195 | 'docs/PEAR-INTEGRATION.md', | |
| 196 | 'packages/core/core/app-registry.js', | |
| 197 | 'packages/core/core/index.js', | |
| 198 | 'packages/core/core/release-lifecycle.js', | |
| 199 | 'packages/core/core/relay-node/app-lifecycle.js', | |
| 200 | 'packages/core/core/relay-node/dedup-report.js', | |
| 201 | 'packages/core/core/relay-node/eviction.js', | |
| 202 | 'packages/core/core/relay-node/index.js', | |
| 203 | 'scripts/lib/publish-drive-sync.mjs', | |
| 204 | 'scripts/lib/release-publisher.mjs', | |
| 205 | 'scripts/publish-app.js', | |
| 206 | 'test/integration/publish-release-rotation.test.js', | |
| 207 | 'test/unit/app-release-rotation.test.js', | |
| 208 | 'test/unit/dedup-reclaim.test.js', | |
| 209 | 'test/unit/dedup-report.test.js', | |
| 210 | 'test/unit/publish-drive-sync.test.js', | |
| 211 | 'test/unit/release-lifecycle.test.js', | |
| 212 | 'test/unit/release-publisher.test.js' | |
| 213 | ] | |
| 214 | const report = buildOwnedDiffReport(paths.map(file => `?? ${file}`).join('\n')) | |
| 215 | ||
| 216 | t.is(report.status, 'pass') | |
| 217 | t.is(report.totals.unknown, 0) | |
| 218 | for (const entry of report.entries) { | |
| 219 | t.ok(entry.owners.some(owner => owner.id === 'hyperdrive-release-lifecycle'), entry.path) | |
| 220 | } | |
| 221 | }) | |
| 222 | ||
| 192 | 223 | test('audit-owned diff blocks unknown development paths', async (t) => { |
| 193 | 224 | const fixture = await tempFile(t, '?? stray-release-note.txt\n') |
| 194 | 225 | const res = await runAudit(['--json', '--status-file', fixture]) |
| … | … | @@ -7,7 +7,7 @@ |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import test from 'brittle' |
| 10 | import { EvictionManager } from 'p2p-hiverelay/core/relay-node/eviction.js' | |
| 10 | import { EvictionManager } from '../../packages/core/core/relay-node/eviction.js' | |
| 11 | 11 | |
| 12 | 12 | const A = 'a'.repeat(64) // older |
| 13 | 13 | const B = 'b'.repeat(64) // newer / current |
| … | … | @@ -119,6 +119,30 @@ test('retainVersions keeps the N newest superseded versions', async (t) => { |
| 119 | 119 | t.alike(unseeds, [A]) |
| 120 | 120 | }) |
| 121 | 121 | |
| 122 | test('signed retainKeys keeps the exact rollback set instead of guessing by count', async (t) => { | |
| 123 | const { ev, unseeds } = harness([ | |
| 124 | [A, app('cool', '1.0.0')], [C, app('cool', '2.0.0')], [B, app('cool', '3.0.0')] | |
| 125 | ], { [A]: 100, [C]: 200, [B]: 300 }) | |
| 126 | const r = await ev.reclaimSuperseded({ dryRun: false, retainKeys: [A] }) | |
| 127 | t.alike(r.reclaimed.map(x => x.appKey), [C], 'exact signed set keeps v1 even though v2 is newer') | |
| 128 | t.alike(unseeds, [C]) | |
| 129 | }) | |
| 130 | ||
| 131 | test('signed release reclamation is scoped to one app and publisher', async (t) => { | |
| 132 | const { ev, unseeds } = harness([ | |
| 133 | [A, app('cool', '1.0.0')], [B, app('cool', '2.0.0')], | |
| 134 | [C, app('unrelated', '1.0.0')], [D, app('unrelated', '2.0.0')] | |
| 135 | ], { [A]: 100, [B]: 200, [C]: 300, [D]: 400 }) | |
| 136 | const r = await ev.reclaimSuperseded({ | |
| 137 | dryRun: false, | |
| 138 | appId: 'cool', | |
| 139 | publisherPubkey: PUB, | |
| 140 | retainKeys: [] | |
| 141 | }) | |
| 142 | t.alike(r.reclaimed.map(x => x.appKey), [A]) | |
| 143 | t.alike(unseeds, [A], 'unrelated superseded app is untouched') | |
| 144 | }) | |
| 145 | ||
| 122 | 146 | test('no superseded versions → nothing reclaimed', async (t) => { |
| 123 | 147 | const { ev } = harness( |
| 124 | 148 | [[A, app('one', '1.0.0')], [B, app('two', '1.0.0')]], { [A]: 100, [B]: 200 }) |
| … | … | @@ -2,14 +2,15 @@ |
| 2 | 2 | * Dedup duplication report — read-only estimate of reclaimable bytes from |
| 3 | 3 | * superseded versions still resident on disk. |
| 4 | 4 | * |
| 5 | * Supersession is decided by VERSION (not the last-writer-wins byAppId index) | |
| 5 | * Supersession is decided by signed release sequence when present, otherwise | |
| 6 | * VERSION (never the last-writer-wins byAppId index) | |
| 6 | 7 | * within a (appId, publisherPubkey) bucket, so an out-of-order boot-replay can |
| 7 | 8 | * never flag the live version as superseded, and an appId collision across |
| 8 | 9 | * different publishers never cross-supersedes. Blind entries never appear. |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | import test from 'brittle' |
| 12 | import { buildDedupReport } from 'p2p-hiverelay/core/relay-node/dedup-report.js' | |
| 13 | import { buildDedupReport } from '../../packages/core/core/relay-node/dedup-report.js' | |
| 13 | 14 | |
| 14 | 15 | const A = 'a'.repeat(64) |
| 15 | 16 | const B = 'b'.repeat(64) |
| … | … | @@ -59,6 +60,15 @@ test('versionless entries are a TIE → never superseded', (t) => { |
| 59 | 60 | t.is(r.supersededVersions.count, 0, 'no version → cannot prove supersession → keep both') |
| 60 | 61 | }) |
| 61 | 62 | |
| 63 | test('signed release sequence supersedes drives even when semver is unchanged', (t) => { | |
| 64 | const r = buildDedupReport(reg([ | |
| 65 | [A, { type: 'app', appId: 'cool', version: '1.0.0', publisherPubkey: PUB, release: { sequence: 4 } }], | |
| 66 | [B, { type: 'app', appId: 'cool', version: '1.0.0', publisherPubkey: PUB, release: { sequence: 5 } }] | |
| 67 | ]), acct({ [A]: 500, [B]: 600 })) | |
| 68 | t.is(r.supersededVersions.groups[0].current, B) | |
| 69 | t.alike(r.supersededVersions.groups[0].superseded, [{ appKey: A, bytes: 500 }]) | |
| 70 | }) | |
| 71 | ||
| 62 | 72 | test('anonymous entries (no publisherPubkey) are never superseded', (t) => { |
| 63 | 73 | const r = buildDedupReport(reg([ |
| 64 | 74 | [A, { type: 'app', appId: 'cool', version: '1.0.0' }], |
| … | … | @@ -0,0 +1,88 @@ |
| 1 | import test from 'brittle' | |
| 2 | import b4a from 'b4a' | |
| 3 | import Corestore from 'corestore' | |
| 4 | import Hyperdrive from 'hyperdrive' | |
| 5 | import { mkdtemp, rm } from 'node:fs/promises' | |
| 6 | import { tmpdir } from 'node:os' | |
| 7 | import { join } from 'node:path' | |
| 8 | import { syncPublishedFiles } from '../../scripts/lib/publish-drive-sync.mjs' | |
| 9 | ||
| 10 | async function fixture (t) { | |
| 11 | const dir = await mkdtemp(join(tmpdir(), 'hiverelay-publish-sync-')) | |
| 12 | const store = new Corestore(dir) | |
| 13 | const drive = new Hyperdrive(store) | |
| 14 | await drive.ready() | |
| 15 | t.teardown(async () => { | |
| 16 | await drive.close() | |
| 17 | await rm(dir, { recursive: true, force: true }) | |
| 18 | }) | |
| 19 | return drive | |
| 20 | } | |
| 21 | ||
| 22 | async function paths (drive) { | |
| 23 | const result = [] | |
| 24 | for await (const entry of drive.list('/')) result.push(entry.key) | |
| 25 | return result.sort() | |
| 26 | } | |
| 27 | ||
| 28 | test('publish sync skips an identical release without growing either core', async (t) => { | |
| 29 | const drive = await fixture(t) | |
| 30 | const files = [ | |
| 31 | { path: '/index.html', content: b4a.from('<h1>Pear</h1>') }, | |
| 32 | { path: '/manifest.json', content: b4a.from('{"version":"1.0.0"}') } | |
| 33 | ] | |
| 34 | ||
| 35 | t.alike(await syncPublishedFiles(drive, files), { | |
| 36 | added: 2, | |
| 37 | changed: 0, | |
| 38 | removed: 0, | |
| 39 | unchanged: 0 | |
| 40 | }) | |
| 41 | const before = { meta: drive.core.length, blobs: drive.blobs.core.length } | |
| 42 | ||
| 43 | t.alike(await syncPublishedFiles(drive, files), { | |
| 44 | added: 0, | |
| 45 | changed: 0, | |
| 46 | removed: 0, | |
| 47 | unchanged: 2 | |
| 48 | }) | |
| 49 | t.alike( | |
| 50 | { meta: drive.core.length, blobs: drive.blobs.core.length }, | |
| 51 | before, | |
| 52 | 'a no-op release appends no metadata or blob blocks' | |
| 53 | ) | |
| 54 | }) | |
| 55 | ||
| 56 | test('publish sync removes stale paths and deduplicates unchanged blocks in changed files', async (t) => { | |
| 57 | const drive = await fixture(t) | |
| 58 | const block = 64 * 1024 | |
| 59 | const first = b4a.concat([b4a.alloc(block, 0x61), b4a.alloc(block, 0x62)]) | |
| 60 | const second = b4a.concat([b4a.alloc(block, 0x61), b4a.alloc(block, 0x63)]) | |
| 61 | ||
| 62 | await syncPublishedFiles(drive, [ | |
| 63 | { path: '/app.bin', content: first }, | |
| 64 | { path: '/stale.txt', content: b4a.from('remove me') } | |
| 65 | ]) | |
| 66 | const blobLength = drive.blobs.core.length | |
| 67 | ||
| 68 | t.alike(await syncPublishedFiles(drive, [ | |
| 69 | { path: '/app.bin', content: second }, | |
| 70 | { path: '/current.txt', content: b4a.from('keep me') } | |
| 71 | ]), { | |
| 72 | added: 1, | |
| 73 | changed: 1, | |
| 74 | removed: 1, | |
| 75 | unchanged: 0 | |
| 76 | }) | |
| 77 | ||
| 78 | t.alike(await paths(drive), ['/app.bin', '/current.txt']) | |
| 79 | t.is(await drive.get('/stale.txt'), null) | |
| 80 | t.ok(b4a.equals(await drive.get('/app.bin'), second), 'changed file reads back exactly') | |
| 81 | ||
| 82 | const changedFileGrowth = drive.blobs.core.length - blobLength - 2 | |
| 83 | t.is( | |
| 84 | changedFileGrowth, | |
| 85 | 2, | |
| 86 | 'changed two-block file appends one changed data block and one block map' | |
| 87 | ) | |
| 88 | }) |
| … | … | @@ -0,0 +1,81 @@ |
| 1 | import test from 'brittle' | |
| 2 | import b4a from 'b4a' | |
| 3 | import sodium from 'sodium-universal' | |
| 4 | import { | |
| 5 | APP_RELEASE_SIGNATURE_DOMAIN, | |
| 6 | hashReleaseTree, | |
| 7 | serializeAppReleaseForSigning, | |
| 8 | signAppRelease, | |
| 9 | verifyAppRelease | |
| 10 | } from '../../packages/core/core/release-lifecycle.js' | |
| 11 | ||
| 12 | function keyPair (byte = 7) { | |
| 13 | const publicKey = b4a.alloc(32) | |
| 14 | const secretKey = b4a.alloc(64) | |
| 15 | sodium.crypto_sign_seed_keypair(publicKey, secretKey, b4a.alloc(32, byte)) | |
| 16 | return { publicKey, secretKey } | |
| 17 | } | |
| 18 | ||
| 19 | function releaseFields (overrides = {}) { | |
| 20 | return { | |
| 21 | protocolVersion: 1, | |
| 22 | appId: 'pear-chat', | |
| 23 | version: '2.0.0', | |
| 24 | sequence: 4, | |
| 25 | generation: 2, | |
| 26 | driveKey: 'b'.repeat(64), | |
| 27 | previousDriveKey: 'a'.repeat(64), | |
| 28 | rotationReason: 'storage-budget', | |
| 29 | storageBudgetBytes: 1024 * 1024, | |
| 30 | rollbackWindow: 3, | |
| 31 | rollbackDriveKeys: ['a'.repeat(64)], | |
| 32 | treeHash: 'c'.repeat(64), | |
| 33 | issuedAt: 1234, | |
| 34 | ...overrides | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | test('app release signs a domain-separated, publisher-bound key rotation', (t) => { | |
| 39 | const signed = signAppRelease(releaseFields(), keyPair()) | |
| 40 | const verified = verifyAppRelease(signed, { now: 1234 }) | |
| 41 | ||
| 42 | t.ok(verified.ok) | |
| 43 | t.is(verified.release.previousDriveKey, 'a'.repeat(64)) | |
| 44 | t.is(verified.release.driveKey, 'b'.repeat(64)) | |
| 45 | t.ok(serializeAppReleaseForSigning(signed).includes(b4a.from(APP_RELEASE_SIGNATURE_DOMAIN))) | |
| 46 | ||
| 47 | t.absent(verifyAppRelease({ ...signed, driveKey: 'd'.repeat(64) }, { now: 1234 }).ok, 'drive-key tamper fails') | |
| 48 | t.absent(verifyAppRelease({ ...signed, rollbackWindow: 2 }, { now: 1234 }).ok, 'retention tamper fails') | |
| 49 | t.absent(verifyAppRelease({ ...signed, publisherPubkey: b4a.toString(keyPair(8).publicKey, 'hex') }, { now: 1234 }).ok, 'publisher substitution fails') | |
| 50 | }) | |
| 51 | ||
| 52 | test('app release rejects unsigned or malformed rotation policy', (t) => { | |
| 53 | const pair = keyPair() | |
| 54 | t.exception( | |
| 55 | () => signAppRelease(releaseFields({ rotationReason: null }), pair), | |
| 56 | /must declare storage-budget/ | |
| 57 | ) | |
| 58 | t.exception( | |
| 59 | () => signAppRelease(releaseFields({ rollbackWindow: 1, rollbackDriveKeys: ['a'.repeat(64)] }), pair), | |
| 60 | /exceeds rollbackWindow/ | |
| 61 | ) | |
| 62 | t.absent(verifyAppRelease({ ...releaseFields(), publisherPubkey: b4a.toString(pair.publicKey, 'hex') }).ok) | |
| 63 | }) | |
| 64 | ||
| 65 | test('release tree hash is path-stable and content-sensitive', (t) => { | |
| 66 | const a = hashReleaseTree([ | |
| 67 | { path: '/z.txt', content: b4a.from('z') }, | |
| 68 | { path: '/a.txt', content: b4a.from('a') } | |
| 69 | ]) | |
| 70 | const reordered = hashReleaseTree([ | |
| 71 | { path: '/a.txt', content: b4a.from('a') }, | |
| 72 | { path: '/z.txt', content: b4a.from('z') } | |
| 73 | ]) | |
| 74 | const changed = hashReleaseTree([ | |
| 75 | { path: '/a.txt', content: b4a.from('A') }, | |
| 76 | { path: '/z.txt', content: b4a.from('z') } | |
| 77 | ]) | |
| 78 | ||
| 79 | t.is(a, reordered) | |
| 80 | t.not(a, changed) | |
| 81 | }) |
| … | … | @@ -0,0 +1,135 @@ |
| 1 | import test from 'brittle' | |
| 2 | import b4a from 'b4a' | |
| 3 | import sodium from 'sodium-universal' | |
| 4 | import { verifyAppRelease } from '../../packages/core/core/release-lifecycle.js' | |
| 5 | import { buildPublisherSignedSeedOpts } from '../../packages/core/core/seed-request-builder.js' | |
| 6 | import { | |
| 7 | ROTATION_POINTER_RESERVE_BYTES, | |
| 8 | createPublisherRelease, | |
| 9 | createSignedSeedRequest, | |
| 10 | parseStorageBytes, | |
| 11 | shouldRotateReleaseDrive | |
| 12 | } from '../../scripts/lib/release-publisher.mjs' | |
| 13 | ||
| 14 | function keyPair () { | |
| 15 | const publicKey = b4a.alloc(32) | |
| 16 | const secretKey = b4a.alloc(64) | |
| 17 | sodium.crypto_sign_seed_keypair(publicKey, secretKey, b4a.alloc(32, 9)) | |
| 18 | return { publicKey, secretKey } | |
| 19 | } | |
| 20 | ||
| 21 | test('publisher release history keeps only drive keys represented by the rollback window', (t) => { | |
| 22 | const pair = keyPair() | |
| 23 | const first = createPublisherRelease({ | |
| 24 | appState: {}, | |
| 25 | appId: 'app', | |
| 26 | version: '1.0.0', | |
| 27 | driveKey: 'a'.repeat(64), | |
| 28 | storageBudgetBytes: 1024 * 1024, | |
| 29 | rollbackWindow: 3, | |
| 30 | treeHash: '1'.repeat(64), | |
| 31 | issuedAt: 1, | |
| 32 | keyPair: pair | |
| 33 | }) | |
| 34 | const second = createPublisherRelease({ | |
| 35 | appState: first.appState, | |
| 36 | appId: 'app', | |
| 37 | version: '2.0.0', | |
| 38 | driveKey: 'b'.repeat(64), | |
| 39 | previousDriveKey: 'a'.repeat(64), | |
| 40 | storageBudgetBytes: 1024 * 1024, | |
| 41 | rollbackWindow: 3, | |
| 42 | treeHash: '2'.repeat(64), | |
| 43 | issuedAt: 2, | |
| 44 | keyPair: pair | |
| 45 | }) | |
| 46 | const third = createPublisherRelease({ | |
| 47 | appState: second.appState, | |
| 48 | appId: 'app', | |
| 49 | version: '3.0.0', | |
| 50 | driveKey: 'c'.repeat(64), | |
| 51 | previousDriveKey: 'b'.repeat(64), | |
| 52 | storageBudgetBytes: 1024 * 1024, | |
| 53 | rollbackWindow: 3, | |
| 54 | treeHash: '3'.repeat(64), | |
| 55 | issuedAt: 3, | |
| 56 | keyPair: pair | |
| 57 | }) | |
| 58 | const fourth = createPublisherRelease({ | |
| 59 | appState: third.appState, | |
| 60 | appId: 'app', | |
| 61 | version: '4.0.0', | |
| 62 | driveKey: 'd'.repeat(64), | |
| 63 | previousDriveKey: 'c'.repeat(64), | |
| 64 | storageBudgetBytes: 1024 * 1024, | |
| 65 | rollbackWindow: 3, | |
| 66 | treeHash: '4'.repeat(64), | |
| 67 | issuedAt: 4, | |
| 68 | keyPair: pair | |
| 69 | }) | |
| 70 | const fifth = createPublisherRelease({ | |
| 71 | appState: fourth.appState, | |
| 72 | appId: 'app', | |
| 73 | version: '4.1.0', | |
| 74 | driveKey: 'd'.repeat(64), | |
| 75 | storageBudgetBytes: 1024 * 1024, | |
| 76 | rollbackWindow: 3, | |
| 77 | treeHash: '5'.repeat(64), | |
| 78 | issuedAt: 5, | |
| 79 | keyPair: pair | |
| 80 | }) | |
| 81 | const sixth = createPublisherRelease({ | |
| 82 | appState: fifth.appState, | |
| 83 | appId: 'app', | |
| 84 | version: '4.2.0', | |
| 85 | driveKey: 'd'.repeat(64), | |
| 86 | storageBudgetBytes: 1024 * 1024, | |
| 87 | rollbackWindow: 3, | |
| 88 | treeHash: '6'.repeat(64), | |
| 89 | issuedAt: 6, | |
| 90 | keyPair: pair | |
| 91 | }) | |
| 92 | ||
| 93 | t.alike(third.release.rollbackDriveKeys, ['b'.repeat(64), 'a'.repeat(64)]) | |
| 94 | t.alike(fourth.release.rollbackDriveKeys, ['c'.repeat(64), 'b'.repeat(64)], 'oldest drive falls outside the three-release window') | |
| 95 | t.is(fourth.appState.releases.length, 3) | |
| 96 | t.is(fourth.release.sequence, 4) | |
| 97 | t.is(fourth.release.generation, 4) | |
| 98 | t.ok(verifyAppRelease(fourth.release, { now: 4 }).ok) | |
| 99 | t.alike(fifth.release.rollbackDriveKeys, ['c'.repeat(64)]) | |
| 100 | t.alike(sixth.release.rollbackDriveKeys, [], 'a predecessor expires once every release in the window lives on the current drive') | |
| 101 | }) | |
| 102 | ||
| 103 | test('budget planner reserves room for a predecessor rotation pointer', (t) => { | |
| 104 | const under = shouldRotateReleaseDrive({ | |
| 105 | driveBytes: 100, | |
| 106 | plan: { contentBytes: 100, writes: [{}], removed: [] }, | |
| 107 | storageBudgetBytes: 100 + 100 + 4096 + ROTATION_POINTER_RESERVE_BYTES | |
| 108 | }) | |
| 109 | const over = shouldRotateReleaseDrive({ | |
| 110 | driveBytes: 100, | |
| 111 | plan: { contentBytes: 101, writes: [{}], removed: [] }, | |
| 112 | storageBudgetBytes: 100 + 100 + 4096 + ROTATION_POINTER_RESERVE_BYTES | |
| 113 | }) | |
| 114 | t.absent(under.rotate) | |
| 115 | t.ok(over.rotate) | |
| 116 | t.is(parseStorageBytes('1GiB'), 1024 ** 3) | |
| 117 | t.is(parseStorageBytes('bad'), null) | |
| 118 | }) | |
| 119 | ||
| 120 | test('publisher emits a replay-hardened seed request under the release identity', (t) => { | |
| 121 | const pair = keyPair() | |
| 122 | const now = 123456 | |
| 123 | const body = createSignedSeedRequest({ | |
| 124 | appKey: 'a'.repeat(64), | |
| 125 | maxStorageBytes: 1024 * 1024, | |
| 126 | keyPair: pair, | |
| 127 | issuedAt: now, | |
| 128 | requestNonce: b4a.alloc(16, 3) | |
| 129 | }) | |
| 130 | const built = buildPublisherSignedSeedOpts(body, { now }) | |
| 131 | t.ok(built.ok) | |
| 132 | t.is(built.opts.publisherPubkey, b4a.toString(pair.publicKey, 'hex')) | |
| 133 | t.is(built.opts.seedSignatureProfile, 'replay-v1') | |
| 134 | t.is(built.opts.maxStorage, 1024 * 1024) | |
| 135 | }) |