vault: a lock that lets go, and seals that say what they broke

The idle timeout was checked only when the key was next asked for, so a
vault nobody touched kept its key in memory indefinitely while status
reported it locked. A ticker now drops the key once it is idle, and
status() expires it too. Vault passwords are Zeroizing from the request
body to Argon2.

Re-sealing a file rewrote links with a case-sensitive replace, and only
in documents linked through node_media. Links are now matched by regex,
whatever their case, in every document and card body that names the id.
A folder re-seal carries on past a file that fails and reports how many
failed. Two re-seals of one file no longer both commit.

:share reports how many sealed files the document embeds, since a share
link never serves them, and :encrypt reports how many shared documents
embed what it just sealed. Vault status counts sealed files, so :export!
asks for the password first rather than navigating into a 423 page. A
sealed embed that fails to load while the vault is shut becomes a button
that unlocks it and puts the embed back.
This commit is contained in:
nvrl committed 2026-10-20 01:43:45 +02:00
1 parent aa34be8663
commit 336df03ee2
11 files changed
+248 -36

No files matched your search

+45
View File
@@ -40,6 +40,38 @@
}
})
/*
* A sealed file embedded here will not load while the vault is shut: the
* server answers 423 and the browser shows a broken image. Errors do not
* bubble, so they are caught on the way down, and the broken embed is
* swapped for a button that opens the vault and puts it back.
*/
$effect(() => {
const root = article
if (!root) return
const sealed = async (event: Event) => {
const element = event.target as HTMLElement
const media = element.closest('img, video, audio') as HTMLImageElement | HTMLMediaElement | null
if (!media || !(media.getAttribute('src') ?? media.querySelector('source')?.getAttribute('src') ?? '').includes('/api/media/')) return
await workspace.loadVault()
if (!workspace.vault?.configured || workspace.vault.unlocked || !media.isConnected) return
const button = document.createElement('button')
button.className = 'sealed-embed'
button.textContent = 'sealed — unlock to show'
button.onclick = async (click) => {
click.preventDefault()
click.stopPropagation()
if (!(await workspace.ensureUnlocked())) return
button.replaceWith(media)
if (media instanceof HTMLImageElement) media.src = media.src
else media.load()
}
media.replaceWith(button)
}
root.addEventListener('error', sealed, true)
return () => root.removeEventListener('error', sealed, true)
})
/**
* `/n/<space>/<slug>` links — the ones the importer writes between imported
* documents — are followed in place rather than reloading the application.
@@ -152,6 +184,19 @@
</section>
<style>
.pane :global(.sealed-embed) {
display: block;
margin: 0.5rem 0;
padding: 1.5rem;
width: 100%;
border: 1px dashed currentColor;
background: none;
color: inherit;
opacity: 0.7;
font: inherit;
cursor: pointer;
}
.pane {
display: flex;
flex-direction: column;