feat: mount spaces/folders over SFTP with SSH-key auth

Add an in-process SFTP server (russh) backed directly by the nodes
table, so a space can be edited from nvim or synced with sshfs without
a separate filesystem mirror. Access is authenticated by public key
against a new ssh_keys table, managed via :sshkey in the app; the
feature stays off until WORKSPACE_SFTP_BIND is set.
This commit is contained in:
nvrl committed 2026-09-21 15:36:36 +02:00
1 parent ab8712559e
commit 747332153c
12 files changed
+1950 -93

No files matched your search

+16
View File
@@ -30,6 +30,15 @@ export interface WorkspaceNode extends NodeSummary {
path: Crumb[]
}
/** A public key allowed to mount the workspace over SFTP. */
export interface SshKey {
id: number
name: string
fingerprint: string
created_at: number
last_used_at: number | null
}
export interface SharedDocument {
name: string
body: string
@@ -248,4 +257,11 @@ export const api = {
readShared: (token: string, key: string) =>
request<SharedDocument>(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`),
listSshKeys: () => request<SshKey[]>('/ssh-keys'),
addSshKey: (name: string, public_key: string) =>
request<SshKey>('/ssh-keys', { method: 'POST', body: body({ name, public_key }) }),
removeSshKey: (id: number) => request<{ ok: true }>(`/ssh-keys/${id}`, { method: 'DELETE' }),
}
+30
View File
@@ -703,6 +703,36 @@ export const commands: CommandSpec[] = [
}),
},
},
{
name: 'sshkey',
args: '[add <name> <key> | rm <name>]',
group: 'view',
help: 'list, add or remove keys allowed to mount the workspace over sftp',
run: (arg) =>
workspace.guard(async () => {
const trimmed = arg.trim()
if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') {
const keys = await api.listSshKeys()
if (keys.length === 0) return workspace.say('no keys registered — :sshkey add <name> <key>')
return workspace.say(keys.map((k) => `${k.name} ${k.fingerprint}`).join(' · '))
}
const addMatch = /^add\s+(\S+)\s+(.+)$/.exec(trimmed)
if (addMatch) {
const [, name, key] = addMatch
const added = await api.addSshKey(name, key)
return workspace.say(`added ${added.name} (${added.fingerprint})`)
}
const rmMatch = /^rm\s+(\S+)$/.exec(trimmed)
if (rmMatch) {
const keys = await api.listSshKeys()
const target = keys.find((k) => k.name === rmMatch[1])
if (!target) return workspace.say(`no key named ${rmMatch[1]}`, 'error')
await api.removeSshKey(target.id)
return workspace.say(`removed ${target.name}`)
}
workspace.say('usage: :sshkey [add <name> <key> | rm <name>]', 'error')
}),
},
{
name: 'help',
aliases: ['h'],