vault: a sealed folder stays sealed

`:encrypt` on a folder sealed the files in it once; whatever arrived
afterwards was plaintext. A folder or space that is encrypted now
carries `sealed` (0015), and so does every folder under it, so "is
this place sealed?" is one column of the parent. What lands there is
sealed on the way in, or refused with 423 while the vault is shut:

- uploads, with or without `?encrypt`, encrypted as they stream
- imports, sealed where they lie before any row names them
- SFTP writes, sealed out of their `.part`, and refused at the open
  when the vault is shut
- files and folders moved in, through the web or SFTP: seal::enter
  seals them first and the move happens only if that worked

Triggers make it hold for any path: a folder made inside a sealed one
is sealed, and a file node whose media is not encrypted cannot be
inserted into or moved into a sealed folder (a 400, not a 500).
Decrypting the sealed folder lifts the flag on its subtree and opens
its files; a file or folder inside it cannot be decrypted on its own.
An empty folder can be sealed. Documents are not files and stay as
they are.

The list shows a lock on sealed folders, the touch menu offers to
unseal one, and an upload or import into one asks for the vault
password first. Tests cover sealing, inheritance, the trigger, moves
with the vault open and shut, unsealing, an SFTP write and an import.
This commit is contained in:
nvrl committed 2026-10-20 10:14:36 +02:00
1 parent 1ae0665206
commit 889f99f8d6
15 files changed
+659 -39

No files matched your search

+5 -4
View File
@@ -15,7 +15,7 @@
const actions = $derived.by((): Action[] => {
if (!node) return []
const sealed = node.file?.encrypted ?? false
const sealed = node.file?.encrypted ?? node.sealed
const holdsFiles = isContainer(node) || node.kind === 'file'
const list: Action[] = [
{ label: isContainer(node) ? 'open' : 'read', run: () => workspace.guard(() => workspace.openSelected()) },
@@ -32,11 +32,12 @@
)
if (node.shared) list.push({ label: 'new share link — the old one stops working', run: () => runCommand('link') })
}
if (holdsFiles && node.kind !== 'space') {
// Inside a sealed folder only that folder opens, as a whole.
if (holdsFiles && node.kind !== 'space' && !(sealed && workspace.parentSealed)) {
list.push(
sealed
? { label: 'take out of the vault', run: () => runCommand('decrypt') }
: { label: node.kind === 'file' ? 'seal in the vault' : 'seal every file in it', run: () => runCommand('encrypt') },
? { label: node.kind === 'file' ? 'take out of the vault' : 'unseal — take every file out', run: () => runCommand('decrypt') }
: { label: node.kind === 'file' ? 'seal in the vault' : 'seal it — now and what lands later', run: () => runCommand('encrypt') },
)
}
list.push({ label: 'delete', run: () => runCommand('delete'), danger: true })
+1
View File
@@ -119,6 +119,7 @@
{#if workspace.marked?.id === node.id}<span class="badge" title="marked — p drops it in a folder">m</span>{/if}
{#if node.shared}<span class="shared" title="shared"></span>{/if}
{#if node.file?.encrypted}<span class="sealed" title="in the vault"><Icon name="lock" size={12} /></span>{/if}
{#if node.sealed}<span class="sealed" title="sealed — files added here go into the vault"><Icon name="lock" size={12} /></span>{/if}
<span class="aside">{aside(node)}</span>
</button>
{#if isTouchDevice}
+7 -2
View File
@@ -30,6 +30,8 @@ export interface NodeSummary {
updated_at: number
shared: boolean
share_token: string | null
/** Folders and spaces only: whatever lands here is sealed in the vault. */
sealed: boolean
/** Files only. */
file: FileInfo | null
/** Search results only: where the match sits, `work/notes`. */
@@ -433,9 +435,12 @@ export const api = {
lockVault: () => request<VaultStatus>('/vault/lock', { method: 'POST' }),
/** Seal a file, or every file under a folder, into the vault — or take it back out. */
/**
* Seal a file into the vault, or a folder with everything in it and all
* that lands there later — or take it back out.
*/
seal: (id: number, encrypt: boolean) =>
request<{ files: number; unchanged: number; failed: number; shared: number }>(`/nodes/${id}/${encrypt ? 'encrypt' : 'decrypt'}`, {
request<{ files: number; unchanged: number; failed: number; shared: number; sealed: boolean }>(`/nodes/${id}/${encrypt ? 'encrypt' : 'decrypt'}`, {
method: 'POST',
}),
+10 -6
View File
@@ -75,12 +75,16 @@ const sealTarget = (encrypt: boolean) =>
if (node.file && node.file.encrypted === encrypt) {
return workspace.say(encrypt ? 'already in the vault' : 'not in the vault')
}
if (!encrypt && workspace.parentSealed) {
return workspace.say(`this ${node.kind} is inside a sealed folder — decrypt that folder instead`, 'error')
}
const name = node.name || node.slug
if (isContainer(node)) {
const verb = encrypt ? 'seal every file in' : 'take every file in'
const tail = encrypt ? 'into the vault' : 'out of the vault'
if (!(await workspace.ask(`${verb} ${node.kind} "${node.name || node.slug}" ${tail}?`))) return
const question = encrypt
? `seal ${node.kind} "${name}" — every file in it now, and every file added later? documents stay readable`
: `unseal ${node.kind} "${name}" and take every file in it out of the vault?`
if (!(await workspace.ask(question))) return
} else if (!encrypt) {
const name = node.name || node.slug
if (!(await workspace.ask(`store "${name}" unencrypted again, and in the readable files/ view on proton drive?`))) return
}
if (!(await workspace.ensureUnlocked())) return
@@ -865,14 +869,14 @@ export const commands: CommandSpec[] = [
name: 'encrypt',
aliases: ['seal'],
group: 'vault',
help: 'seal the file under the cursor in the vault — on a folder, every file inside it',
help: 'seal the file under the cursor in the vault — a folder stays sealed: every file in it, and every file added later',
run: () => sealTarget(true),
},
{
name: 'decrypt',
aliases: ['unseal'],
group: 'vault',
help: 'take the file under the cursor back out of the vault — on a folder, every file inside it',
help: 'take the file under the cursor back out of the vault — on a sealed folder, unseal it and every file inside',
run: () => sealTarget(false),
},
{
+12 -2
View File
@@ -39,6 +39,8 @@ class Workspace {
parentId = $state<number | null>(null)
/** Ancestors of the current folder, the space first and the folder last. */
path = $state<Crumb[]>([])
/** The current folder is sealed: uploads into it go into the vault. */
parentSealed = $state(false)
/** Children of the current folder, or the results of a search. */
nodes = $state<NodeSummary[]>([])
@@ -250,14 +252,15 @@ class Workspace {
/** Seal what is under the cursor, or take it back out, and show the result. */
async seal(id: number, encrypt: boolean) {
const { files, unchanged, failed, shared } = await api.seal(id, encrypt)
const { files, unchanged, failed, shared, sealed } = await api.seal(id, encrypt)
const verb = encrypt ? 'encrypted' : 'decrypted'
const already = unchanged > 0 ? `, ${unchanged} already were` : ''
const broken = failed > 0 ? `, ${failed} could not be` : ''
const later = sealed ? ' — what lands in the folder from now on is sealed too' : ''
const readers =
shared > 0 ? ` — ${shared} shared document${shared === 1 ? '' : 's'} embed it, and their readers no longer see it` : ''
this.say(
`${verb} ${files} file${files === 1 ? '' : 's'}${already}${broken}${readers}`,
`${verb} ${files} file${files === 1 ? '' : 's'}${already}${broken}${later}${readers}`,
failed > 0 || shared > 0 ? 'error' : undefined,
)
await this.refresh()
@@ -573,6 +576,7 @@ class Workspace {
// Walking into another space by a link leaves this one where it was.
if (this.spaceId !== null && folder.path[0]?.id !== this.spaceId) this.#rememberSpot()
this.parentId = id
this.parentSealed = folder.sealed
this.query = ''
this.globalSearch = false
this.path = folder.path
@@ -1199,6 +1203,7 @@ class Workspace {
async importFolder(files: File[], root = false) {
const parent = this.parentId
if (parent === null && !root) return
if (!root && this.parentSealed && !(await this.ensureUnlocked())) return
this.say(`importing ${files.length} file${files.length === 1 ? '' : 's'} …`)
const report = root ? await api.importRoot(files) : await api.importFolder(parent!, files)
@@ -1224,6 +1229,11 @@ class Workspace {
async putFiles(files: File[], encrypt = false) {
const parent = this.parentId
if (parent === null) return
// A sealed folder seals them anyway, and needs the vault open for it.
if (this.parentSealed) {
if (!(await this.ensureUnlocked())) return
encrypt = true
}
const total = files.reduce((sum, file) => sum + file.size, 0)
const what = files.length === 1 ? files[0].name : `${files.length} files`
this.say(`uploading ${what} (${formatBytes(total)})${encrypt ? ' into the vault' : ''} …`)