OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay commits / 4a2ecf6

feat: bound Hyperdrive release growth safely
4a2ecf6c2e8661b30c471341a0053b6947b683e3
by bigdestiny2 <33146784+bigdestiny2@users.noreply.github.com> · 2026-07-29T20:26:27.000Z

Changed files (21) +1887 -82

docs/APP-RELEASE-LIFECYCLE.md +73 -0
……@@ -0,0 +1,73 @@
1# Bounded app release storage
2
3HiveRelay app publishing now uses three linked controls:
4
51. 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.
82. 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.
123. 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
17Relays verify the signed statement against the pinned manifest, recompute its
18content-tree hash from that exact pinned Hyperdrive version, and bind it to the
19publisher from the seed request and the seed storage bound. A relay only
20accepts a key rotation when it names the currently served predecessor. Release
21authority is durably persisted before reclamation can begin and survives relay
22restart. Reclamation is scoped to the exact app id and publisher, retains the
23signed rollback key set, and sends only older drives from that release chain
24through the existing unseed, tombstone, and purge path. Archive, custody, and
25unexpired paid-lease entries remain non-purgable.
26
27## Publisher usage
28
29```sh
30node 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
39identity. The defaults are a 1 GiB budget per drive and three retained
40releases. The rollback window accepts 1 through 32 releases.
41
42Publisher 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
48Back up the publisher storage. The signing seed is the authority for the
49release chain, while the Corestore contains the writable Hyperdrive keys.
50
51When no relay URLs are supplied and `--no-stay` is set, the script performs an
52offline 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
54request to `/api/v1/seed`.
55
56## Storage semantics
57
58The publisher reserves 64 KiB inside each budget for the signed predecessor
59pointer and uses a conservative metadata estimate before writing. A single
60release that cannot fit in a fresh drive plus that reserve is rejected with the
61estimated required size.
62
63The rollback window is measured in signed releases. Multiple releases can live
64in the same drive, so the retained drive-key list is deduplicated. On relays,
65signed release sequence is authoritative for supersession; legacy unsigned
66entries continue to use semantic version ordering until that publisher adopts
67a signed chain. Once adopted, an older unsigned manifest cannot regain release
68authority by claiming a larger semantic version.
69
70Blind releases still receive publisher-side delta syncing, budgeting, and a
71signed encrypted rotation pointer. Relays preserve their existing blind-drive
72non-inspection contract, so automatic manifest-based rollback reclamation is
73limited to non-blind releases.
docs/PEAR-INTEGRATION.md +3 -1
……@@ -62,7 +62,9 @@ The practical path is:
62621. Build a Hyperdrive.
63632. Include a root `manifest.json`.
64643. 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).
66684. Seed the resulting drive on one or more relays.
67695. Verify that the drive is reachable through both P2P and relay HTTP.
6870
packages/core/core/app-registry.js +45 -6
……@@ -9,7 +9,7 @@
99 * - Disk persistence (auto-saves on every mutation)
1010 * - Startup recovery (loads from disk, reseeds drives)
1111 * - 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)
1313 */
1414
1515import { readFile, writeFile, rename, unlink } from 'fs/promises'
……@@ -19,6 +19,7 @@ import Hyperbee from 'hyperbee'
1919import b4a from 'b4a'
2020import sodium from 'sodium-universal'
2121import { STORAGE_COMMITMENT_METADATA_OVERHEAD_BYTES } from '../config/storage-admission-authority.js'
22import { verifyAppRelease } from './release-lifecycle.js'
2223import {
2324 compareVersions,
2425 normalizeAvailabilityClass,
……@@ -77,6 +78,36 @@ function sumBlockBytes (blocks) {
7778 return total
7879}
7980
81function 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
93function 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
80111function validatedLegacyEntries (parsed) {
81112 if (!parsed || typeof parsed !== 'object') throw registryInventoryError('unsupported-json-shape')
82113 const entries = Array.isArray(parsed) ? parsed : Object.values(parsed)
……@@ -92,6 +123,7 @@ function validatedLegacyEntries (parsed) {
92123 validatePersistedStorageBound(entry)
93124 validatePersistedStorageProof(entry)
94125 validatePersistedMetadataBudget(entry)
126 validatePersistedRelease(entry, appKey)
95127 if (b4a.byteLength(JSON.stringify(entry)) + APP_REGISTRY_LEGACY_ROW_PREFLIGHT_MARGIN_BYTES >
96128 STORAGE_COMMITMENT_METADATA_OVERHEAD_BYTES) {
97129 throw registryInventoryError('storage-metadata-row-exceeds-commitment')
……@@ -153,7 +185,7 @@ const MAX_TOMBSTONES = 5000
153185
154186/**
155187 * 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()
157189 * applies inline (see ~`if (type === 'app')` below); factored here so the P2P
158190 * broadcast can't drift from the HTTP view. Rows that are non-`app` or carry a
159191 * null id (redacted/blind) are never collapsed — they pass through untouched.
……@@ -168,7 +200,7 @@ function dedupLatestByAppId (rows, idField = 'appId') {
168200 if (idx === undefined) {
169201 seen.set(id, out.length)
170202 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) {
172204 out[idx] = r
173205 }
174206 }
……@@ -991,12 +1023,12 @@ export class AppRegistry extends EventEmitter {
9911023 custodyIntentId: entry.custodyIntentId || null
9921024 }, entry, opts)
9931025
994 // Dedup app entries by appId — keep latest version
1026 // Dedup app entries by appId — signed sequence, legacy version fallback.
9951027 if (type === 'app') {
9961028 const existingIdx = seen.get(appId)
9971029 if (existingIdx !== undefined) {
9981030 const existing = items[existingIdx]
999 if (compareVersions(catalogEntry.version, existing.version) > 0) {
1031 if (compareAppReleaseRows(catalogEntry, existing) > 0) {
10001032 items[existingIdx] = catalogEntry
10011033 }
10021034 } else {
……@@ -1037,6 +1069,7 @@ export class AppRegistry extends EventEmitter {
10371069 parentKey: redacted ? null : (entry.parentKey || null),
10381070 mountPath: redacted ? null : (entry.mountPath || null),
10391071 version: redacted ? null : (entry.version || null),
1072 release: redacted ? null : (entry.release || null),
10401073 discoveryKey: entry.discoveryKey
10411074 ? (redacted ? null : (typeof entry.discoveryKey === 'string' ? entry.discoveryKey : entry.discoveryKey.toString('hex')))
10421075 : null,
……@@ -1067,7 +1100,7 @@ export class AppRegistry extends EventEmitter {
10671100 retainUntil: redacted ? null : (entry.retainUntil || null)
10681101 })
10691102 }
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
10711104 // does this for the HTTP view, but the P2P broadcast previously emitted one
10721105 // row per registry entry, leaking superseded versions to peers. Redacted
10731106 // rows (appId null) + non-'app' types pass through untouched.
……@@ -1184,6 +1217,7 @@ export class AppRegistry extends EventEmitter {
11841217 } else {
11851218 validatePersistedStorageBound(entry)
11861219 validatePersistedStorageProof(entry)
1220 validatePersistedRelease(entry, appKey)
11871221 }
11881222 seen.add(appKey)
11891223 inventory.push({ appKey, entry, metadataBudget, tombstone })
……@@ -1534,6 +1568,7 @@ export class AppRegistry extends EventEmitter {
15341568 _hydrateEntry (appKey, entry) {
15351569 const hasStorageProof = STORAGE_PROOF_FIELDS.every(field => entry[field] != null)
15361570 const metadataBudget = validatePersistedMetadataBudget(entry)
1571 const release = validatePersistedRelease(entry, appKey)
15371572 const hydrated = {
15381573 startedAt: entry.startedAt || entry.seededAt || Date.now(),
15391574 appId: entry.appId || entry.name || null,
……@@ -1556,6 +1591,7 @@ export class AppRegistry extends EventEmitter {
15561591 shardIds: Array.isArray(entry.shardIds) ? entry.shardIds : null,
15571592 privacyTier: entry.privacyTier || 'public',
15581593 publisherPubkey: entry.publisherPubkey || null,
1594 release,
15591595 durability: Number.isFinite(entry.durability) ? entry.durability : 0,
15601596 revocable: entry.revocable !== false,
15611597 categories: entry.categories || null,
……@@ -1615,6 +1651,7 @@ export class AppRegistry extends EventEmitter {
16151651 retainUntil: e.retainUntil || null,
16161652 shardIds: Array.isArray(e.shardIds) ? e.shardIds : null,
16171653 publisherPubkey: e.publisherPubkey || null,
1654 release: e.release || null,
16181655 durability: Number.isFinite(e.durability) ? e.durability : 0,
16191656 revocable: e.revocable !== false,
16201657 maxStorage: Number.isSafeInteger(e.maxStorage) && e.maxStorage > 0
……@@ -1634,6 +1671,7 @@ export class AppRegistry extends EventEmitter {
16341671 // field can be coerced to null below and silently turn a poisoned partial
16351672 // proof into a legacy-looking row that fails only on the next restart.
16361673 const hasStorageProof = validatePersistedStorageProof(entry)
1674 const release = validatePersistedRelease(entry, appKey)
16371675 const shape = {
16381676 appKey,
16391677 appId: entry.appId || null,
……@@ -1656,6 +1694,7 @@ export class AppRegistry extends EventEmitter {
16561694 shardIds: Array.isArray(entry.shardIds) ? entry.shardIds : null,
16571695 privacyTier: entry.privacyTier || 'public',
16581696 publisherPubkey: entry.publisherPubkey || null,
1697 release,
16591698 durability: Number.isFinite(entry.durability) ? entry.durability : 0,
16601699 revocable: entry.revocable !== false,
16611700 categories: entry.categories || null,
packages/core/core/index.js +9 -0
……@@ -9,6 +9,15 @@ export {
99 parseSeedProtocolHandshake,
1010 evaluateSeedProtocolHandshake
1111} from './protocol/seed-request.js'
12export {
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'
1221export { CircuitRelay } from './protocol/relay-circuit.js'
1322export { ProofOfRelay } from './protocol/proof-of-relay.js'
1423export { BandwidthReceipt } from './protocol/bandwidth-receipt.js'
packages/core/core/relay-node/app-lifecycle.js +247 -7
……@@ -9,6 +9,9 @@ import { isAbortError } from './lifecycle-scope.js'
99import { verifyShareBundleForRelay } from '../pvss.js'
1010import { STORAGE_SHARE_BUNDLE_MAX_BYTES } from '../../config/storage-admission-authority.js'
1111import { positiveStorageBound } from '../../config/storage-cap.js'
12import { hashReleaseTree, verifyAppRelease } from '../release-lifecycle.js'
13import { EvictionManager } from './eviction.js'
14import { compareSupersessionEntries } from './dedup-report.js'
1215import {
1316 isValidHexKey,
1417 normalizeAvailabilityClass,
……@@ -21,6 +24,46 @@ import {
2124// external callers from forging the serve-only path through JSON/options.
2225const STORAGE_RECOVERY_INGRESS = Symbol('storage-recovery-ingress')
2326
27function 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
46function 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
2467function seedAdmissionTimeoutError (timeoutMs) {
2568 const err = new Error(`Seed admission timed out after ${timeoutMs}ms`)
2669 err.code = 'STORAGE_SEED_ADMISSION_TIMEOUT'
……@@ -91,6 +134,30 @@ export class AppLifecycle extends EventEmitter {
91134 return this.node.appRegistry.apps
92135 }
93136
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
94161 _joinDriveDiscovery (appKeyHex, discoveryKey, handles = null) {
95162 const handle = this.node.swarm.join(discoveryKey, { server: true, client: true })
96163 const entry = this.node.appRegistry.get(appKeyHex)
……@@ -504,6 +571,7 @@ export class AppLifecycle extends EventEmitter {
504571 throw err
505572 }
506573 this._recordCustodyReceiptOnRepin(appKeyHex, existing, normalizedOpts)
574 this._refreshExistingSeed(appKeyHex, existing, normalizedOpts)
507575 return { discoveryKey: dkHex, alreadySeeded: true }
508576 }
509577 // else: placeholder entry from load() — fall through to seed properly.
……@@ -547,6 +615,7 @@ export class AppLifecycle extends EventEmitter {
547615 throw err
548616 }
549617 this._recordCustodyReceiptOnRepin(appKeyHex, existing, opts)
618 this._refreshExistingSeed(appKeyHex, existing, opts)
550619 return { discoveryKey: dkHex, alreadySeeded: true }
551620 }
552621 // else: placeholder entry from load() — fall through.
……@@ -751,6 +820,7 @@ export class AppLifecycle extends EventEmitter {
751820 retainUntil: Number.isFinite(opts.retainUntil) ? opts.retainUntil : null,
752821 shardIds: Array.isArray(opts.shardIds) ? opts.shardIds : null,
753822 publisherPubkey,
823 release: opts.release || null,
754824 revocable,
755825 unseedFreezeMs,
756826 durability,
……@@ -914,6 +984,15 @@ export class AppLifecycle extends EventEmitter {
914984 return promise
915985 }
916986
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
917996 async _downloadProvedDriveRanges (drive, proof, opts = {}) {
918997 const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 120_000
919998 const signal = opts.signal || null
……@@ -1537,6 +1616,50 @@ export class AppLifecycle extends EventEmitter {
15371616 }
15381617 }
15391618
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
15401663 /**
15411664 * Read manifest.json from a drive and deduplicate by appId.
15421665 * If an older version of the same app is already seeded, unseed it.
……@@ -1580,7 +1703,78 @@ export class AppLifecycle extends EventEmitter {
15801703 ? manifest.mountPath.trim()
15811704 : existing?.mountPath || null
15821705
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
15841778 node.appRegistry.update(appKeyHex, {
15851779 type: contentType,
15861780 parentKey,
……@@ -1599,14 +1793,36 @@ export class AppLifecycle extends EventEmitter {
15991793 name: manifest.name || appId,
16001794 description: manifest.description || '',
16011795 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 }
16041818
16051819 if (contentType !== 'app') return
16061820 if (!appId) return
16071821
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.
16101826 if (conflict.conflict) {
16111827 if (conflict.shouldReplace) {
16121828 this.emit('app-replaced', {
……@@ -1614,10 +1830,34 @@ export class AppLifecycle extends EventEmitter {
16141830 oldKey: conflict.existingKey,
16151831 oldVersion: conflict.existingVersion,
16161832 newKey: appKeyHex,
1617 newVersion: version
1833 newVersion: version,
1834 signedRotation: Boolean(signedRelease && !sameDriveContinuation)
16181835 })
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 }
16201845 } 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 }
16211861 this.emit('app-version-rejected', {
16221862 appId,
16231863 rejectedKey: appKeyHex,
packages/core/core/relay-node/dedup-report.js +0 -0
(no content changes — binary or rename)
packages/core/core/relay-node/eviction.js +18 -5
……@@ -36,8 +36,7 @@ import { EventEmitter } from 'events'
3636import b4a from 'b4a'
3737import Hyperdrive from 'hyperdrive'
3838import { raceTimeout } from './storage-accounting.js'
39import { buildDedupReport } from './dedup-report.js'
40import { compareVersions } from '../constants.js'
39import { buildDedupReport, compareSupersessionEntries } from './dedup-report.js'
4140
4241const DEFAULTS = {
4342 enabled: false,
……@@ -245,12 +244,22 @@ export class EvictionManager extends EventEmitter {
245244 * @param {object} [opts]
246245 * @param {boolean} [opts.dryRun=true] report-only (no deletes) by default
247246 * @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
248250 * @param {number} [opts.max=Infinity] cap reclaims this pass
249251 * @returns {{dryRun, reclaimed:[], skipped:[], freedBytes, candidates}}
250252 */
251253 async reclaimSuperseded (opts = {}) {
252254 const dryRun = opts.dryRun !== false
253255 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
254263 const max = Number.isFinite(opts.max) ? opts.max : Infinity
255264 const now = Date.now()
256265
……@@ -260,9 +269,13 @@ export class EvictionManager extends EventEmitter {
260269 let freedBytes = 0
261270
262271 for (const group of report.supersededVersions.groups) {
272 if (appId && group.appId !== appId) continue
273 if (publisherPubkey && group.publisherPubkey?.toLowerCase() !== publisherPubkey) continue
263274 const current = this.deps.appRegistry.get(group.current)
264275 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) {
266279 // Keep the retainVersions newest superseded versions; reclaim the rest.
267280 // Deterministic order: version desc, then born-time desc, then appKey —
268281 // so versionless/equal-version ties don't reclaim on iteration order.
……@@ -271,7 +284,7 @@ export class EvictionManager extends EventEmitter {
271284 const e = this.deps.appRegistry.get(s.appKey) || {}
272285 return { ...s, version: e.version || '0.0.0', bornAt: e.seededAt || e.startedAt || 0 }
273286 })
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))
275288 .slice(retainVersions)
276289 }
277290 for (const s of candidates) {
……@@ -285,7 +298,7 @@ export class EvictionManager extends EventEmitter {
285298 // against drift between report-build and now.
286299 if (!current ||
287300 !entry.publisherPubkey || entry.publisherPubkey !== current.publisherPubkey ||
288 compareVersions(entry.version || '0.0.0', current.version || '0.0.0') >= 0) {
301 compareSupersessionEntries(entry, current) >= 0) {
289302 skipped.push({ appKey: s.appKey, reason: 'supersession-unverified' })
290303 continue
291304 }
packages/core/core/relay-node/index.js +1 -1
……@@ -671,7 +671,7 @@ export class RelayNode extends EventEmitter {
671671 this.appRegistry = new AppRegistry(this.config.storage)
672672 this.appLifecycle = new AppLifecycle(this)
673673 // 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']) {
675675 this.appLifecycle.on(ev, (payload) => this._emitSafely(ev, payload))
676676 }
677677 this.connections = new Map() // conn -> { lastActivity }
packages/core/core/release-lifecycle.js +216 -0
……@@ -0,0 +1,216 @@
1import b4a from 'b4a'
2import sodium from 'sodium-universal'
3
4export const APP_RELEASE_PROTOCOL_VERSION = 1
5export const APP_RELEASE_SIGNATURE_DOMAIN = 'hiverelay.app-release.v1'
6export const MAX_RELEASE_ROLLBACK_WINDOW = 32
7
8const 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
26function 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
32function 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
41function 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
124function 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
143export 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
152export 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
164export 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
179function 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
185function 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
191export 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}
scripts/lib/audit-owned-diff.mjs +27 -0
……@@ -82,6 +82,33 @@ export const AUDIT_OWNED_DIFF_SLICES = Object.freeze([
8282 'test/unit/audit-owned-diff.test.js'
8383 ]
8484 },
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 },
85112 {
86113 id: 'dispatch-route-helper',
87114 title: 'HTTP dispatch route helper extraction',
scripts/lib/publish-drive-sync.mjs +102 -0
……@@ -0,0 +1,102 @@
1import b4a from 'b4a'
2
3function 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
18async 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
25function 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 */
42export 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
78export 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}
scripts/lib/release-publisher.mjs +224 -0
……@@ -0,0 +1,224 @@
1import b4a from 'b4a'
2import sodium from 'sodium-universal'
3import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
4import { join } from 'node:path'
5import { signAppRelease } from '../../packages/core/core/release-lifecycle.js'
6import { serializeSeedRequestForReplaySigning } from '../../packages/core/core/protocol/seed-request.js'
7
8export const DEFAULT_RELEASE_STORAGE_BUDGET = 1024 * 1024 * 1024
9export const DEFAULT_RELEASE_ROLLBACK_WINDOW = 3
10export const ROTATION_POINTER_RESERVE_BYTES = 64 * 1024
11export const RELEASE_STATE_FILE = 'release-state.json'
12
13export 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
31export 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
42export 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
52export 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
60export 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
85export 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
129function priorReleaseHistory (appState) {
130 return Array.isArray(appState?.releases)
131 ? appState.releases.filter(release => release && typeof release.driveKey === 'string')
132 : []
133}
134
135export 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
206export 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
212export 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
218export 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}
scripts/publish-app.js +237 -59
……@@ -17,6 +17,8 @@
1717 * --relays <urls> Comma-separated relay API URLs to seed on
1818 * --storage <path> Corestore path (default: .publisher-storage)
1919 * --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)
2022 * --bootstrap <nodes> Comma-separated DHT bootstrap nodes (host:port)
2123 * --api-key <key> Shared operator API key sent to every relay
2224 * (fallback: HIVERELAY_API_KEY env var)
……@@ -42,6 +44,22 @@ import sodium from 'sodium-universal'
4244import { readdir, readFile, writeFile, stat, mkdir, rename } from 'fs/promises'
4345import { join, relative, resolve } from 'path'
4446import { existsSync } from 'fs'
47import { hashReleaseTree } from '../packages/core/core/release-lifecycle.js'
48import { planPublishedFiles, syncPublishedFiles } from './lib/publish-drive-sync.mjs'
49import {
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'
4563
4664const RELAY_DISCOVERY_TOPIC = b4a.alloc(32)
4765sodium.crypto_generichash(RELAY_DISCOVERY_TOPIC, b4a.from('hiverelay-discovery-v1'))
……@@ -63,6 +81,8 @@ function parseArgs (argv) {
6381 version: '1.0.0',
6482 relays: DEFAULT_RELAYS,
6583 storage: '.publisher-storage',
84 storageBudgetBytes: DEFAULT_RELEASE_STORAGE_BUDGET,
85 rollbackWindow: DEFAULT_RELEASE_ROLLBACK_WINDOW,
6686 key: null,
6787 bootstrap: DEFAULT_BOOTSTRAP,
6888 apiKey: process.env.HIVERELAY_API_KEY || null,
……@@ -78,8 +98,26 @@ function parseArgs (argv) {
7898 if (arg === '--id') { opts.id = args[++i]; continue }
7999 if (arg === '--desc') { opts.description = args[++i]; continue }
80100 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 }
82102 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 }
83121 if (arg === '--key') { opts.key = args[++i]; continue }
84122 if (arg === '--bootstrap') {
85123 try {
……@@ -164,6 +202,13 @@ function deriveAppId (name) {
164202 return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
165203}
166204
205function 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
167212function parseBootstrapNodes (input) {
168213 if (!input) return null
169214 const nodes = String(input)
……@@ -197,7 +242,9 @@ async function loadDriveMap (storagePath) {
197242async function saveDriveMap (storagePath, map) {
198243 await mkdir(storagePath, { recursive: true })
199244 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)
201248}
202249
203250async function walkDir (dir, base) {
……@@ -268,15 +315,27 @@ async function resolveFromRelay (relays, appId) {
268315 return null
269316}
270317
271async function seedOnRelay (relayUrl, appKey, appId, version, blind, apiKey) {
318async function seedOnRelay (relayUrl, appKey, appId, version, blind, apiKey, maxStorageBytes, keyPair) {
272319 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
277335 const headers = { 'Content-Type': 'application/json' }
278336 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, {
280339 method: 'POST',
281340 headers,
282341 body: JSON.stringify(body)
……@@ -313,6 +372,8 @@ async function run () {
313372 console.error(' --version <version> App version (default: 1.0.0)')
314373 console.error(' --storage <path> Publisher storage path')
315374 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)')
316377 console.error(' --api-key <key> Shared operator API key (env: HIVERELAY_API_KEY)')
317378 console.error(' --api-keys <map> Per-relay keys url=key,url=key (env: HIVERELAY_API_KEYS)')
318379 console.error(' --blind Encrypt content (relay can\'t read, P2P only)')
……@@ -328,6 +389,10 @@ async function run () {
328389 }
329390
330391 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 }
331396
332397 console.log()
333398 console.log('=== HiveRelay App Publisher ===')
……@@ -337,6 +402,8 @@ async function run () {
337402 console.log(' Relays: ', opts.relays.length)
338403 if (opts.bootstrap) console.log(' Bootstrap:', opts.bootstrap.map(n => `${n.host}:${n.port}`).join(', '))
339404 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'))
340407 if (opts.version !== '1.0.0') console.log(' Version: ', opts.version)
341408 if (opts.blind) console.log(' Mode: BLIND (encrypted, P2P only)')
342409 console.log()
……@@ -353,8 +420,11 @@ async function run () {
353420 const store = new Corestore(opts.storage)
354421 await store.ready()
355422
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))
358428
359429 // For blind mode: use explicit key, load saved key, or generate new one
360430 let encryptionKey = null
……@@ -373,6 +443,14 @@ async function run () {
373443 // Hyperdrive constructor options (with optional encryption)
374444 const driveOpts = encryptionKey ? { encryptionKey } : {}
375445
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
376454 // Resolve drive key: explicit --key > saved mapping > new drive
377455 let drive
378456 let isUpdate = false
……@@ -385,9 +463,10 @@ async function run () {
385463 } else if (appId) {
386464 // Check saved appId → key mapping
387465 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)
391470 isUpdate = true
392471 } else {
393472 // No local mapping — check relay registry (handles publisher storage loss)
……@@ -414,26 +493,24 @@ async function run () {
414493
415494 if (!drive) {
416495 console.log(' Creating new Hyperdrive...')
417 drive = new Hyperdrive(store, null, driveOpts)
496 drive = createReleaseDrive(store, driveOpts)
418497 }
419498
420499 await drive.ready()
421500
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 }
426504
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')
432508 }
433509
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,
437514 name: opts.name || 'Unknown App',
438515 description: opts.description || (opts.name ? opts.name + ' — published via HiveRelay' : ''),
439516 version: opts.version,
……@@ -449,44 +526,141 @@ async function run () {
449526 // Merge user's manifest with our fields (user fields take priority)
450527 try {
451528 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
455533 console.log(' Using user-provided manifest.json (merged with publisher metadata)')
456534 } catch (_) {
457535 console.log(' Warning: could not parse user manifest.json, using generated one')
458536 }
459537 }
460538
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`)
473602 }
474603 }
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)
476609 console.log()
477610
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
478638 // 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 }
484649 console.log()
485650
486651 // Seed on relays (pass appId + version for deduplication)
487652 console.log(' Seeding on relays...')
488653 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 ))
490664 )
491665
492666 let seeded = 0
……@@ -507,7 +681,7 @@ async function run () {
507681 console.error(' ┌─────────────────────────────────────────────────────────────────┐')
508682 console.error(' │ AUTH FAILURE: ' + authFailures.length + '/' + opts.relays.length + ' relay(s) returned 401 Unauthorized. │')
509683 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:')
511685 for (const f of authFailures) {
512686 console.error(' ' + f.url + (f.keySent ? ' (a key WAS sent — wrong key for this relay?)' : ' (no key sent)'))
513687 }
……@@ -559,13 +733,15 @@ async function run () {
559733
560734 // Monitor replication
561735 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 }
569745
570746 if (opts.stay) {
571747 console.log(' Publisher staying online for replication. Ctrl+C to exit.')
……@@ -584,12 +760,14 @@ async function run () {
584760 } else {
585761 console.log(' Waiting ' + opts.holdSeconds + 's for initial replication...')
586762 await new Promise(resolve => setTimeout(resolve, opts.holdSeconds * 1000))
587 await swarm.destroy()
763 if (swarm) await swarm.destroy()
588764 await store.close()
589765 }
590766}
591767
592run().catch(err => {
768try {
769 await run()
770} catch (err) {
593771 console.error('Fatal:', err)
594772 process.exit(1)
595})
773}
test/integration/publish-release-rotation.test.js +68 -0
……@@ -0,0 +1,68 @@
1import test from 'brittle'
2import b4a from 'b4a'
3import Corestore from 'corestore'
4import Hyperdrive from 'hyperdrive'
5import { spawn } from 'node:child_process'
6import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
7import { tmpdir } from 'node:os'
8import { join, resolve } from 'node:path'
9import { verifyAppRelease } from '../../packages/core/core/release-lifecycle.js'
10
11function 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
37test('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})
test/unit/app-release-rotation.test.js +245 -0
……@@ -0,0 +1,245 @@
1import test from 'brittle'
2import b4a from 'b4a'
3import sodium from 'sodium-universal'
4import { mkdtemp, rm } from 'node:fs/promises'
5import { tmpdir } from 'node:os'
6import { join } from 'node:path'
7import { AppRegistry } from '../../packages/core/core/app-registry.js'
8import { AppLifecycle } from '../../packages/core/core/relay-node/app-lifecycle.js'
9import { hashReleaseTree, signAppRelease } from '../../packages/core/core/release-lifecycle.js'
10
11const OLD = 'a'.repeat(64)
12const NEW = 'b'.repeat(64)
13const WRONG = 'c'.repeat(64)
14const BUDGET = 1024 * 1024
15const CONTENT = b4a.from('verified release content')
16const TREE_HASH = hashReleaseTree([{ path: '/index.html', content: CONTENT }])
17
18function 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
25function 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
44function 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
62function 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
82function 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
113test('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
130test('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
147test('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
174test('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
188test('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
203test('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
214test('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
225test('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})
test/unit/audit-owned-diff.test.js +31 -0
……@@ -189,6 +189,37 @@ test('audit-owned diff accepts known audit slice paths', async (t) => {
189189 .owners.some(owner => owner.id === 'outboxlog-blind-recipient-trust-root-helper'))
190190})
191191
192test('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
192223test('audit-owned diff blocks unknown development paths', async (t) => {
193224 const fixture = await tempFile(t, '?? stray-release-note.txt\n')
194225 const res = await runAudit(['--json', '--status-file', fixture])
test/unit/dedup-reclaim.test.js +25 -1
……@@ -7,7 +7,7 @@
77 */
88
99import test from 'brittle'
10import { EvictionManager } from 'p2p-hiverelay/core/relay-node/eviction.js'
10import { EvictionManager } from '../../packages/core/core/relay-node/eviction.js'
1111
1212const A = 'a'.repeat(64) // older
1313const B = 'b'.repeat(64) // newer / current
……@@ -119,6 +119,30 @@ test('retainVersions keeps the N newest superseded versions', async (t) => {
119119 t.alike(unseeds, [A])
120120})
121121
122test('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
131test('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
122146test('no superseded versions → nothing reclaimed', async (t) => {
123147 const { ev } = harness(
124148 [[A, app('one', '1.0.0')], [B, app('two', '1.0.0')]], { [A]: 100, [B]: 200 })
test/unit/dedup-report.test.js +12 -2
……@@ -2,14 +2,15 @@
22 * Dedup duplication report — read-only estimate of reclaimable bytes from
33 * superseded versions still resident on disk.
44 *
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)
67 * within a (appId, publisherPubkey) bucket, so an out-of-order boot-replay can
78 * never flag the live version as superseded, and an appId collision across
89 * different publishers never cross-supersedes. Blind entries never appear.
910 */
1011
1112import test from 'brittle'
12import { buildDedupReport } from 'p2p-hiverelay/core/relay-node/dedup-report.js'
13import { buildDedupReport } from '../../packages/core/core/relay-node/dedup-report.js'
1314
1415const A = 'a'.repeat(64)
1516const B = 'b'.repeat(64)
……@@ -59,6 +60,15 @@ test('versionless entries are a TIE → never superseded', (t) => {
5960 t.is(r.supersededVersions.count, 0, 'no version → cannot prove supersession → keep both')
6061})
6162
63test('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
6272test('anonymous entries (no publisherPubkey) are never superseded', (t) => {
6373 const r = buildDedupReport(reg([
6474 [A, { type: 'app', appId: 'cool', version: '1.0.0' }],
test/unit/publish-drive-sync.test.js +88 -0
……@@ -0,0 +1,88 @@
1import test from 'brittle'
2import b4a from 'b4a'
3import Corestore from 'corestore'
4import Hyperdrive from 'hyperdrive'
5import { mkdtemp, rm } from 'node:fs/promises'
6import { tmpdir } from 'node:os'
7import { join } from 'node:path'
8import { syncPublishedFiles } from '../../scripts/lib/publish-drive-sync.mjs'
9
10async 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
22async function paths (drive) {
23 const result = []
24 for await (const entry of drive.list('/')) result.push(entry.key)
25 return result.sort()
26}
27
28test('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
56test('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})
test/unit/release-lifecycle.test.js +81 -0
……@@ -0,0 +1,81 @@
1import test from 'brittle'
2import b4a from 'b4a'
3import sodium from 'sodium-universal'
4import {
5 APP_RELEASE_SIGNATURE_DOMAIN,
6 hashReleaseTree,
7 serializeAppReleaseForSigning,
8 signAppRelease,
9 verifyAppRelease
10} from '../../packages/core/core/release-lifecycle.js'
11
12function 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
19function 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
38test('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
52test('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
65test('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})
test/unit/release-publisher.test.js +135 -0
……@@ -0,0 +1,135 @@
1import test from 'brittle'
2import b4a from 'b4a'
3import sodium from 'sodium-universal'
4import { verifyAppRelease } from '../../packages/core/core/release-lifecycle.js'
5import { buildPublisherSignedSeedOpts } from '../../packages/core/core/seed-request-builder.js'
6import {
7 ROTATION_POINTER_RESERVE_BYTES,
8 createPublisherRelease,
9 createSignedSeedRequest,
10 parseStorageBytes,
11 shouldRotateReleaseDrive
12} from '../../scripts/lib/release-publisher.mjs'
13
14function 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
21test('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
103test('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
120test('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})