fix: allow explicit renewal of expired rentals

This commit is contained in:
archipelago
2026-10-10 09:55:43 -04:00
parent 2dc6121eea
commit a4df61000c
9 changed files with 359 additions and 10 deletions
+42
View File
@@ -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(), &params.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,
+77
View File
@@ -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<BuyerRecord> {
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();
@@ -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,
@@ -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<B: Serialize + Sync, R: DeserializeOwned>(
&self,
route: &str,
+159
View File
@@ -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.
@@ -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}))
@@ -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<Quote & { completed_bytes?: number; total_bytes?: number }>({ 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 }
@@ -176,18 +176,19 @@
Find Nodes
</button>
</div>
<div class="flex gap-2 sm:contents">
<div class="grid grid-cols-2 gap-2 sm:contents">
<button @click="router.push({ name: 'federation', query: { view: 'map' } })" class="mobile-card-action glass-button rounded-lg flex-1 w-full px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
Map
</button>
<button
@click="refreshActiveTab"
:disabled="loadingPeers || loadingRequests"
class="mobile-card-action glass-button rounded-lg min-h-11 w-11 flex items-center justify-center text-white/80 hover:text-white disabled:opacity-50"
class="mobile-card-action glass-button rounded-lg min-h-11 w-full sm:w-11 flex items-center justify-center text-xs sm:text-sm font-medium text-white/80 hover:text-white disabled:opacity-50"
:aria-label="loadingPeers || loadingRequests ? 'Refreshing connected nodes' : 'Refresh connected nodes'"
title="Refresh connected nodes"
>
<svg class="w-4 h-4" :class="{ 'animate-spin': loadingPeers || loadingRequests }" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11a8 8 0 10-2.4 6M20 4v7h-7" /></svg>
<span class="sm:hidden">{{ loadingPeers || loadingRequests ? 'Refreshing…' : 'Refresh' }}</span>
<svg class="hidden sm:block w-4 h-4" :class="{ 'animate-spin': loadingPeers || loadingRequests }" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11a8 8 0 10-2.4 6M20 4v7h-7" /></svg>
</button>
</div>
</nav>
@@ -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 () => {