diff --git a/core/archipelago/src/api/rpc/purchase.rs b/core/archipelago/src/api/rpc/purchase.rs index aa31e3a0..6dfcd1bf 100644 --- a/core/archipelago/src/api/rpc/purchase.rs +++ b/core/archipelago/src/api/rpc/purchase.rs @@ -144,6 +144,8 @@ impl RpcHandler { struct RentalParams { #[serde(default)] retry_preparation: bool, + #[serde(default)] + renew_expired: bool, seller_did: String, content_id: String, expected_sha256: String, @@ -209,6 +211,46 @@ impl RpcHandler { price_sats: params.expected_price_sats, viewing_seconds: params.expected_viewing_seconds, }; + if params.renew_expired { + let journal = Journal::open(&self.config.data_dir).await?; + let matching = journal + .find_buyers(&buyer, transport.seller_did(), ¶ms.content_id) + .await?; + let settled: Vec<_> = matching + .into_iter() + .filter(|record| record.phase == crate::content_purchase::BuyerPhase::ReceiptSaved) + .collect(); + anyhow::ensure!( + settled.len() <= 1, + "Multiple original rentals require recovery" + ); + if let Some(record) = settled.into_iter().next() { + let capability = record + .receipt() + .context("Original rental receipt is missing")? + .capability + .clone(); + let original_duration = journal + .protocol_envelope("buyer", &record.contract.id) + .await? + .context("Original rental terms are missing")? + .offer + .viewing_seconds + .context("Original purchase is not a timed rental")?; + let (started, expires) = transport + .expired_rental_window(&record.contract, &capability, original_duration) + .await?; + journal + .record_rental_expiry( + &record.contract, + started, + expires, + original_duration, + u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0), + ) + .await?; + } + } match caller::purchase_bound( &self.config.data_dir, &buyer, diff --git a/core/archipelago/src/content_purchase.rs b/core/archipelago/src/content_purchase.rs index 1a29469d..ab025b0b 100644 --- a/core/archipelago/src/content_purchase.rs +++ b/core/archipelago/src/content_purchase.rs @@ -1167,6 +1167,40 @@ impl Journal { } Ok(record) } + + /// Retire a settled timed-rental receipt only after the authenticated + /// seller has reported its immutable viewing window as expired. This is a + /// lifecycle transition, not evidence that a permanent copy was cached: + /// it merely permits a later, explicitly approved rental operation for the + /// same registered title. Incomplete and still-active purchases must remain + /// recoverable under their original UUID. + pub async fn record_rental_expiry( + &self, + contract: &Contract, + started_at: u64, + expires_at: u64, + viewing_seconds: u64, + now: u64, + ) -> Result { + let mut record = self.bound_buyer(contract).await?; + anyhow::ensure!( + contract.content_id.starts_with("registered_") + && record.phase == BuyerPhase::ReceiptSaved + && record.receipt.is_some(), + "Only a settled timed rental can be retired" + ); + anyhow::ensure!( + viewing_seconds > 0 + && started_at > 0 + && started_at.checked_add(viewing_seconds) == Some(expires_at) + && now >= expires_at, + "Original rental viewing period has not ended" + ); + record.phase = BuyerPhase::Delivered; + record.validate()?; + self.write("buyer", &contract.id, &record).await?; + Ok(record) + } } #[cfg(test)] @@ -1365,6 +1399,49 @@ mod tests { assert!(journal.buyer(&expired.id).await.unwrap().is_none()); } #[tokio::test] + async fn only_an_expired_settled_rental_can_be_retired_for_another_period() { + let root = tempfile::tempdir().unwrap(); + let mut rental = contract(); + rental.content_id = "registered_film-1".into(); + let journal = Journal::open(root.path()).await.unwrap(); + journal.prepare_buyer(&rental, 1100).await.unwrap(); + let seller = journal.prepare_seller(&rental, 1100).await.unwrap(); + journal + .record_acceptance(&rental, &seller.acceptance().unwrap(), &rental.seller_did) + .await + .unwrap(); + let encoded = token(&rental, "rental-expiry"); + journal.record_token(&rental, &encoded).await.unwrap(); + journal + .record_incoming_token(&rental, &encoded) + .await + .unwrap(); + journal + .record_settlement(&rental, rental.minimum_net_sats) + .await + .unwrap(); + let receipt = journal.issue_receipt(&rental).await.unwrap(); + journal.record_receipt(&rental, &receipt).await.unwrap(); + + assert!(journal + .record_rental_expiry(&rental, 2000, 5600, 3600, 5599) + .await + .is_err()); + assert_eq!( + journal.buyer(&rental.id).await.unwrap().unwrap().phase, + BuyerPhase::ReceiptSaved + ); + let retired = journal + .record_rental_expiry(&rental, 2000, 5600, 3600, 5600) + .await + .unwrap(); + assert_eq!(retired.phase, BuyerPhase::Delivered); + + let mut next = rental.clone(); + next.id = uuid::Uuid::new_v4().to_string(); + journal.prepare_buyer(&next, 1500).await.unwrap(); + } + #[tokio::test] async fn damaged_records_and_nonregular_targets_are_preserved() { let root = tempfile::tempdir().unwrap(); let contract = contract(); diff --git a/core/archipelago/src/content_purchase_caller.rs b/core/archipelago/src/content_purchase_caller.rs index 3747ee60..4e296fda 100644 --- a/core/archipelago/src/content_purchase_caller.rs +++ b/core/archipelago/src/content_purchase_caller.rs @@ -206,7 +206,10 @@ pub(crate) async fn purchase_bound( .await?; let unresolved: Vec<_> = matching .into_iter() - .filter(|record| record.phase != BuyerPhase::Cancelled) + .filter(|record| { + record.phase != BuyerPhase::Cancelled + && !(expected.is_some() && record.phase == BuyerPhase::Delivered) + }) .collect(); anyhow::ensure!( unresolved.len() <= 1, diff --git a/core/archipelago/src/content_purchase_transport.rs b/core/archipelago/src/content_purchase_transport.rs index 656eb129..a5505c0c 100644 --- a/core/archipelago/src/content_purchase_transport.rs +++ b/core/archipelago/src/content_purchase_transport.rs @@ -39,6 +39,60 @@ impl FipsPurchaseTransport { self.retry_preparation = retry; self } + + /// Ask the authenticated seller for the original rental window without + /// starting or extending it. The capability never crosses into the app. + pub async fn expired_rental_window( + &self, + contract: &crate::content_purchase::Contract, + capability: &str, + viewing_seconds: u64, + ) -> Result<(u64, u64)> { + let route = format!( + "/content/{}/rental/{}/prepare", + contract.content_id, contract.id + ); + let body = serde_json::json!({"capability":capability,"ready_id":null,"retry":false}); + let (mut response, _) = + crate::fips::dial::PeerRequest::new(Some(&self.fips_npub), &self.onion, &route) + .require_fips() + .single_delivery() + .timeout(Duration::from_secs(20)) + .send_rental_control_json(&self.data_dir, &self.seller_did, &body) + .await?; + anyhow::ensure!( + response.status().is_success(), + "Original rental status is unavailable" + ); + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + anyhow::ensure!( + bytes + .len() + .checked_add(chunk.len()) + .is_some_and(|n| n <= 16 * 1024), + "Rental status response is too large" + ); + bytes.extend_from_slice(&chunk); + } + let value: serde_json::Value = serde_json::from_slice(&bytes)?; + anyhow::ensure!( + value["state"] == "expired", + "Original rental viewing period is still available" + ); + let started = value["started_at"] + .as_u64() + .context("Missing rental start")?; + let expires = value["expires_at"] + .as_u64() + .context("Missing rental expiry")?; + anyhow::ensure!( + value["viewing_seconds"].as_u64() == Some(viewing_seconds) + && started.checked_add(viewing_seconds) == Some(expires), + "Seller changed the original rental window" + ); + Ok((started, expires)) + } async fn post( &self, route: &str, diff --git a/docs/release-from-latest-main.md b/docs/release-from-latest-main.md new file mode 100644 index 00000000..d117654b --- /dev/null +++ b/docs/release-from-latest-main.md @@ -0,0 +1,159 @@ +# Release OTA and ISO from latest `main` + +This is the operator handoff for producing a release from whatever commit is +the reviewed tip of `main` at the time. A release is not ready merely because +the artifacts build. Every active regression, live-node acceptance, mirror and +publication gate below must pass. + +## 1. Freeze an exact reviewed source revision + +Use a fresh clone or clean worktree. Do not build from an UAT node or a dirty +tree. + +```bash +git switch main +git pull --ff-only ngit main +git status --short +git rev-parse HEAD +python3 scripts/check-git-mirrors.py --local --all +``` + +`git status --short` must be empty. The full mirror audit must show matching +advertised branches/tags on ngit and Gitea. Inventory and resolve any unrelated +drift; never force-push, delete refs, or rewrite published history merely to +make the check green. Record the accepted ngit proposal and merge commit in the +release acceptance ledger. + +Read and retain every unchecked item in: + +- `docs/post-1.8.22-regressions-20261001.md` +- the current release acceptance ledger and UAT checklist + +Run the source gates and the documented live-node matrix. On a node with +installed apps, backend tests must run only through the isolation wrapper: + +```bash +scripts/test-backend-isolated.sh +tests/release/run.sh +python3 scripts/check-app-catalog-drift.py --release --strict +``` + +Keep source/unit results separate from actual-node acceptance. In particular, +prove that paid-file recovery never makes a second payment and that app cleanup +preserves wallets, persistent data, and uninstall decisions. + +## 2. Prepare and sign the OTA release + +Choose a new SemVer that has never been published. Do not move or replace an +existing release tag. Curate the new top entry in `CHANGELOG.md`, then preview: + +```bash +release_version=X.Y.Z-alpha +scripts/create-release.sh "$release_version" --dry-run +``` + +When all gates and review are complete, run the real preparation from clean +`main` in an interactive terminal: + +```bash +scripts/create-release.sh "$release_version" +``` + +The script builds the backend, frontend, AIUI and radio tools, creates a signed +pending manifest and staged OTA artifacts, commits the release preparation, and +creates annotated tag `v$release_version`. During the signing prompt, paste the +24-word release master mnemonic **once**, press Enter, then Ctrl-D on the next +line. Never put the mnemonic in a command, file, chat, log, or Git. Do not use +`RELEASE_MASTER_MNEMONIC` for the normal operator ceremony. + +If an already-prepared pending manifest needs signing on the offline/operator +terminal, first ensure the release binary was built from the exact frozen +commit, then run: + +```bash +bash scripts/sign-manifest.sh \ + "releases/pending/v${release_version}/manifest.json" +``` + +The script cryptographically verifies the result against the public release +root pinned in the binary. A failed verification is a hard stop. + +Review the preparation commit, tag target, staged artifacts and signature. Push +the exact reviewed commit/tag to both publication mirrors according to the +ngit-first workflow; do not create independent merges on each platform. + +## 3. Build and sign the installer ISO + +The ISO builder requires clean `main`, matching versions, the annotated tag and +the signed live manifest. After OTA publication has safely promoted the live +manifest (next section), return to the exact tagged clean tree and run: + +```bash +scripts/build-iso-release.sh +``` + +For a normal release, do **not** pass `--skip-gates` or `--no-qemu`. The command +runs the release harness, strict catalog check, artifact checks, ISO build, +mount-level smoke test and headless QEMU boot. Keep its final PASS summary as +release evidence. + +Sign the generated ISO checksum document in the operator ceremony: + +```bash +iso_path=/absolute/path/to/archipelago-${release_version}.iso +bash scripts/sign-iso-checksums.sh "$iso_path" +core/target/release/archipelago ceremony verify \ + "$iso_path.sha256.json" +sha256sum -c "$iso_path.sha256" +``` + +The signing script again expects one mnemonic paste, Enter, then Ctrl-D. It +does not place the mnemonic on disk. + +## 4. Publish without exposing incomplete updates + +Configure the Gitea remote with a public HTTPS URL and keep its credentials in +Git's credential helper, never in the remote URL. Then run: + +```bash +scripts/publish-release-assets.sh "$release_version" gitea-vps2 +``` + +This script deliberately pushes the tag first, creates the release, uploads and +byte-verifies the OTA assets, and pushes the manifest-bearing `main` last. When +the signed ISO files exist, the same command attaches and verifies them. Never +manually publish `releases/manifest.json` before its referenced assets are +downloadable and verified. + +After publishing, mirror the exact accepted `main` commit and annotated tag to +ngit and Gitea, then verify both: + +```bash +python3 scripts/check-git-mirrors.py --local \ + --ref "refs/tags/v${release_version}" +python3 scripts/check-git-mirrors.py --local --all +scripts/check-release-assets.sh releases/manifest.json +``` + +A failed push, missing ref, tag-object mismatch, unavailable mirror, incomplete +asset, or mismatched byte hash blocks publication. A main-only check is not full +historical mirror parity. + +## 5. Canary and final acceptance + +Before announcing the release: + +1. Apply OTA to one non-critical canary from the signed manifest. +2. Reboot it and verify management health, signer/native identity, installed and + deliberately removed apps, wallet state, persistent app data, terminal, + networking/FIPS, and the active regression checklist. +3. Exercise rollback/recovery without changing wallets or app data. +4. Boot the ISO in QEMU and on representative physical hardware; perform a clean + install and an upgrade-path check, then repeat the same acceptance matrix. +5. Verify public release downloads and both Git mirrors once more. Record exact + commit, annotated tag object, artifact hashes, signer verification, node + evidence, ngit proposal/merge, known deferrals and operator acceptance in the + release ledger. + +Do not call the release complete if any required live acceptance, signature, +mirror, asset, OTA reboot/rollback, or ISO boot/install result is missing. diff --git a/neode-ui/src/composables/__tests__/useRentalPurchaseBridge.test.ts b/neode-ui/src/composables/__tests__/useRentalPurchaseBridge.test.ts index 5f640999..01748a05 100644 --- a/neode-ui/src/composables/__tests__/useRentalPurchaseBridge.test.ts +++ b/neode-ui/src/composables/__tests__/useRentalPurchaseBridge.test.ts @@ -33,6 +33,15 @@ describe('native rental confirmation', () => { expect(calls[1]![0].params.max_wallet_debit).toBe(10); expect(f.bridge.request.value).toBeNull() expect(f.child.postMessage.mock.lastCall![0].result).toEqual({playbackProtocol:2,handle:'d'.repeat(64),expires_at:null,operation_id:quote.operation_id}); expect(rpc.call.mock.calls.some(([v])=>v.method==='content.playback-start')).toBe(false) }) + it('forwards an explicit expired-period renewal without changing its reviewed terms', async () => { + const f=fixture() + await f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',offer,renewExpired:true},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent) + await f.bridge.review() + const call=rpc.call.mock.calls.find(([v])=>v.method==='content.rental-purchase')![0] + expect(call.params.renew_expired).toBe(true) + expect(call.params.consent).toBeUndefined() + expect(call.params).toMatchObject({content_id:offer.terms.contentId,expected_price_sats:offer.terms.priceSats,expected_viewing_seconds:offer.terms.viewingSeconds}) + }) it('does not dispatch after closing during installation validation', async()=>{ const f=fixture(); await f.send(); let release!:(value:unknown)=>void rpc.call.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve})) diff --git a/neode-ui/src/composables/useRentalPurchaseBridge.ts b/neode-ui/src/composables/useRentalPurchaseBridge.ts index 70f01be1..8f4f3f81 100644 --- a/neode-ui/src/composables/useRentalPurchaseBridge.ts +++ b/neode-ui/src/composables/useRentalPurchaseBridge.ts @@ -5,7 +5,7 @@ import { installedOriginMatches } from './useMediaRegistrationBridge' interface FrameContext { appId: () => string; appUrl: () => string; frameWindow: () => Window | null; consentBusy?: () => boolean } export interface RentalOffer { title: string; terms: { nodeDid: string; contentId: string; sha256: string; priceSats: number; viewingSeconds: number } } interface Quote { network: 'mainnet' | 'testnet'; mint_url: string; state: string; operation_id: string; envelope_sha256: string; wallet_debit_sats: number; gross_token_sats: number; seller_net_sats: number; expires_at: number; seller_onion: string } -interface Pending { source: Window; origin: string; id: string; offer: RentalOffer; quote?: Quote } +interface Pending { source: Window; origin: string; id: string; offer: RentalOffer; renewExpired: boolean; quote?: Quote } type PlaybackAction = 'status' | 'prepare' | 'start' interface PlaybackBinding { duration?: number; startedAt?: number; expiresAt?: number } const readyPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ @@ -99,6 +99,7 @@ export function useRentalPurchaseBridge(context: FrameContext) { result = await rpcClient.call({ method: 'content.rental-purchase', params: { seller_did: terms.nodeDid, content_id: terms.contentId, expected_sha256: terms.sha256, expected_price_sats: terms.priceSats, expected_viewing_seconds: terms.viewingSeconds, + renew_expired: item.renewExpired, ...(!confirm && attempt === 0 ? { retry_preparation: true } : {}), max_wallet_debit: confirm ? item.quote!.wallet_debit_sats : Number.MAX_SAFE_INTEGER, ...(confirm ? { consent: { operation_id: item.quote!.operation_id, @@ -192,7 +193,8 @@ export function useRentalPurchaseBridge(context: FrameContext) { || !Number.isSafeInteger(terms.viewingSeconds) || terms.viewingSeconds < 1) { (event.source as Window).postMessage({ type: 'archipelago-rental-response', id, error: 'Invalid rental terms.' }, event.origin); return } - const item: Pending = { source: event.source as Window, origin: event.origin, id, offer: structuredClone(offer) } + if (event.data.renewExpired !== undefined && typeof event.data.renewExpired !== 'boolean') return + const item: Pending = { source: event.source as Window, origin: event.origin, id, offer: structuredClone(offer), renewExpired: event.data.renewExpired === true } pending = item try { await installed(item); if (current(item)) { request.value = item.offer; phase.value = 'review' } } catch (cause) { reply(item, undefined, String(cause)); if (pending === item) pending = null } diff --git a/neode-ui/src/views/web5/Web5ConnectedNodes.vue b/neode-ui/src/views/web5/Web5ConnectedNodes.vue index af8a8fcc..fbcbf40b 100644 --- a/neode-ui/src/views/web5/Web5ConnectedNodes.vue +++ b/neode-ui/src/views/web5/Web5ConnectedNodes.vue @@ -176,18 +176,19 @@ Find Nodes -
+
diff --git a/neode-ui/src/views/web5/__tests__/Web5ConnectedNodes.test.ts b/neode-ui/src/views/web5/__tests__/Web5ConnectedNodes.test.ts index 75800a61..b609859e 100644 --- a/neode-ui/src/views/web5/__tests__/Web5ConnectedNodes.test.ts +++ b/neode-ui/src/views/web5/__tests__/Web5ConnectedNodes.test.ts @@ -48,21 +48,23 @@ describe('Web5ConnectedNodes', () => { sessionStorage.clear() }) - it('puts the three federation destinations and icon refresh in the bottom bar', async () => { + it('uses full-width paired mobile rows and a text refresh control', async () => { const wrapper = mount(Web5ConnectedNodes) const nav = wrapper.get('nav[aria-label="Node connections"]') expect(nav.text()).toContain('My connections') expect(nav.text()).toContain('Find Nodes') expect(nav.text()).toContain('Map') expect(nav.text()).not.toContain('Network Map') - const mobilePrimaryRow = nav.get('div.grid.grid-cols-2') - expect(mobilePrimaryRow.findAll('button')).toHaveLength(2) - expect(mobilePrimaryRow.findAll('button').every(button => button.classes().includes('w-full'))).toBe(true) + const mobileRows = nav.findAll('div.grid.grid-cols-2') + expect(mobileRows).toHaveLength(2) + expect(mobileRows.every(row => row.findAll('button').length === 2)).toBe(true) + expect(mobileRows.flatMap(row => row.findAll('button')).every(button => button.classes().includes('w-full'))).toBe(true) await nav.findAll('button')[0]!.trigger('click') await nav.findAll('button')[1]!.trigger('click') await nav.findAll('button')[2]!.trigger('click') expect(pushRoute.mock.calls.map(([route]) => route.query.view)).toEqual(['connected', 'discover', 'map']) expect(nav.findAll('button')[3]!.attributes('aria-label')).toBe('Refresh connected nodes') + expect(nav.findAll('button')[3]!.text()).toBe('Refresh') }) it('shows a loading state for empty trusted nodes while peers are loading', async () => {