capture: a token writes down from outside the browser

POST /api/capture takes a line of text and puts it on a card in an inbox,
or at the end of today's diary entry, starting the entry when there is none
yet. It answers a logged-in browser, and also a request that carries an
Authorization: Bearer token from :token add, so a phone shortcut, a script
or curl can capture with no tab open.

A token is nwc_ and 32 random bytes. It is shown once, copied, and stored
only as its SHA-256, the way a share key is. Only the capture route reads
the header. The Session extractor never does, so a token opens nothing
else. Wrong tokens go through the login's backoff on a throttle of their
own. A removed token is refused from the next request on, because each
capture looks the hash up again.

A card lands where :card puts one outside the board: the inbox of the space
named by "space", or else of the first space with a board. That rule now
lives server-side as boards::inbox_of. The first line of the text becomes
the title and the rest becomes the notes. Diary text becomes "- HH:MM text"
in WORKSPACE_TIMEZONE, appended to the first document written today at the
top of the diary. That is the entry :today opens. The append keeps a
revision and indexes links like any other save.

:token lists, adds and removes tokens, and completes their names after rm.
The message that shows a new token stays in the status bar instead of
fading, because it cannot be shown again.
This commit is contained in:
nvrl committed 2026-10-20 23:05:17 +02:00
1 parent a42f9074a2
commit f629f5af97
13 files changed
+758 -9

No files matched your search

+53
View File
@@ -153,6 +153,9 @@ const folderNames = () => [
const spaceNames = () => workspace.spaces.map((space) => space.slug)
/** The capture tokens' names, for Tab after `:token rm`; null until fetched. */
let tokenNames: string[] | null = null
/** `:encrypt` and `:decrypt`: a file, or everything under a folder. */
const sealTarget = (encrypt: boolean) =>
workspace.guard(async () => {
@@ -1169,6 +1172,56 @@ export const commands: CommandSpec[] = [
workspace.say('usage: :sshkey [add <name> <key> | rm <name> | vault <name> on|off]', 'error')
}),
},
{
name: 'token',
args: '[add <name> | rm <name>]',
group: 'view',
help: 'list, add or remove tokens that capture into the workspace from outside it — add shows the token once and copies it',
complete: (arg) => {
// Tab cannot wait for the server, so the names are fetched the first
// time it is pressed here and offered from then on.
if (tokenNames === null) {
tokenNames = []
void api.listTokens().then((tokens) => (tokenNames = tokens.map((t) => t.name)), () => (tokenNames = null))
}
return arg.startsWith('rm ') ? tokenNames.map((name) => `rm ${name}`) : ['add ', 'rm ']
},
run: (arg) =>
workspace.guard(async () => {
const trimmed = arg.trim()
if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') {
const tokens = await api.listTokens()
tokenNames = tokens.map((t) => t.name)
if (tokens.length === 0) return workspace.say('no tokens — :token add <name> makes one')
return workspace.say(
tokens
.map((t) => `${t.name}, ${t.last_used_at === null ? 'never used' : `used ${relative(t.last_used_at)}`}`)
.join(' · '),
)
}
const addMatch = /^add\s+(.+)$/.exec(trimmed)
if (addMatch) {
const added = await api.addToken(addMatch[1].trim())
tokenNames = null
const copied = await workspace.copy(added.token)
return workspace.say(
`token ${added.name}: ${added.token} — ${copied ? 'copied; ' : ''}shown this once, the server keeps only its hash`,
'info',
true,
)
}
const rmMatch = /^rm\s+(.+)$/.exec(trimmed)
if (rmMatch) {
const tokens = await api.listTokens()
const target = tokens.find((t) => t.name === rmMatch[1].trim())
if (!target) return workspace.say(`no token named ${rmMatch[1].trim()}`, 'error')
await api.removeToken(target.id)
tokenNames = null
return workspace.say(`removed ${target.name} — it captures nothing from now on`)
}
workspace.say('usage: :token [add <name> | rm <name>]', 'error')
}),
},
{
name: 'vault',
group: 'vault',