Driving the engine from a program
Every screen a person reads before acting, and every action those screens offer, has a --json form: one object on standard output, nothing else on it, every key always present -- a consumer that has to test for a missing key is a consumer writing its own parser. A refusal is an object too, {"ok": false, "error": "<code>", "message": "<a sentence>"}, with a zero exit: the status answers whether an answer arrived at all, the object says what it was. (The engine falling over is the one thing that still leaves with 1 and prose on stderr.) This is the contract add --json and publish --yes --json have kept since the phone first needed them; the rest of the engine keeps it now.
Three rules follow from "nobody is at the keyboard":
- A question the dialog asks is a flag. The slug typed back to confirm a delete is
--yes; the date the schedule dialog asks for is--at; "shift the rest of the queue forward?" is--compact; "rebuild and deploy now?" is--rebuild; "publish it though one of the site's languages has no text for it yet?" is--allow-partial. Without the flag the answer is no -- a program that did not say it did not mean it. (On a site of more than one language that last one decides whether anything can be scheduled or published at all before it is translated: without it both answerpartial_translation-- unless the site itself sayspublishing.allow_partial: true, whichprops --jsonshows aslanguages.allow_partial, and then neither does.) - Those flags are for
--jsononly.delete <slug> --yeswithout--jsonis refused: at the terminal the dialog is there to ask, and a flag that silently skips a confirmation is how a shell history deletes a post. - A post is named by its slug, and never guessed. The same slug in two years is refused as
ambiguous_slugwhere the terminal would show both and ask;list --jsoncarries the year so a program can tell them apart, and will be able to name one. A slug no post has isnot_found.
| command | what it answers | refusals |
|---|---|---|
version --json | engine, site: name, claim, url, lang, locales, accent (light, dark: the palette's accent as colors.css resolves it), palette (light, dark, each bg, text, meta_text, border, resolved the same way) -- the identity block as data; answers on a broken config with what it can | -- |
list [--type=<type>] [--tag=<tag>] [--search=<words>] [--drafts] --json | posts (slug, year, date, title, type, tags, state, scheduled, series, pinned, match), count, drafts, search; --search="words" is the search browse runs behind [/] -- the same query language, over the whole text -- and match is the line that says why a row is there (null when the hit is in the title or a tag); search says the query back, null when there was none. A filter's value is written after = or as the next word (--tag=cats, --tag cats); a filter with nothing after it (--tag, or --tag=), or a word the command has no use for, is an abort -- but an empty --search= is a query, which matches nothing | -- |
props <slug> --json | the properties screen as data, with actions: the keys it would offer this post; network says which one [t] announces on, slot the time the schedule dialog would offer a plain draft; type_set, hero and toc are the [e] rows in the words --set takes back, and so is series_part -- a string or null, though the post's file holds a number; languages names the site's own (own) and says of each other one (others) none, started or written -- the three states check --languages --json calls missing, title_only and written; languages.allow_partial is true on a site that lets a post out without the words of every language (publishing.allow_partial), where publish and schedule need no --allow-partial | not_found, ambiguous_slug |
props <slug> --set key=value ... --json | the screen after the write (deploy, warnings added); keys: series, series_part, tags, type, unlisted, hero, toc, pinned -- the words the screen shows (yes/no, default for hero and toc, - to clear) | bad_property, busy, not_found, ambiguous_slug |
props <slug> --drop-address <address> [--kind <kind>] --json | the screen after the drop. The address is one addresses lists -- each with its kind: former_slugs, redirect_from, or translations.<lang>.former_slugs for an old address of the post's text in another language. An address two of the lists hold is taken out of the one --kind names | address_unknown, address_ambiguous (in two lists, and no --kind), kind_unknown (no such list holds it), kind_needs_drop, kind_once, busy, not_found, ambiguous_slug |
props <slug> --rename <slug> --yes [--rebuild] --json | the screen of the renamed post; a draft's preview is rebuilt, a published post waits for --rebuild | busy, rename_unusable, rename_too_long, rename_same, rename_taken, rename_unreadable, not_found, ambiguous_slug |
props <slug> --versions --json | versions: name, date, label, newest first | not_found, ambiguous_slug |
props <slug> --restore-version <name> --yes --json | the screen after the restore, the preview rebuilt | busy, version_unknown, version_unreadable, not_found, ambiguous_slug |
queue --json | queue: position, date, slug, year, title, overdue; scheduler: last_run, when something last ran the queue, null where nothing ever has | -- |
queue --up <slug> --json, --down <slug>, --move <slug> --to <n> | the queue afterwards, warnings; nothing is rebuilt -- the screen rebuilds once on the way out, a program calls rebuild --json when it is done | not_scheduled, ambiguous_slug, not_moved (first, last, or its time has passed), bad_position, overdue, busy |
toot <slug> [--force] --json, bluesky <slug> [--force] --json | url, recovered (Bluesky found the announcement on the account instead of sending one) | busy, wrong_network, no_network, still_draft, already_announced, unlisted, outside_window (unless --force), failed, not_sent, not_found, ambiguous_slug |
drafts --json [<slug>] | the drafts with their text, as the phone opens them; every entry says omitted -- true where its text was left out of a long answer and has to be asked for by name | not_found |
translate <slug> --lang <code> --json | the post's text in that language as the editor opens it (empty when there is none yet), the original beside it, the post's media by name, preview (the address of the post's own page -- not of its page in that language), base:, and editable with problem as edit --json says them; the words come back through add of a file saying edits:, base: and lang: -- a title and a body both empty take the language off the post, and what the text could not carry is refused (content_lost) | no_locales, own_language, unknown_language, not_found, ambiguous_slug |
edit <slug> --json | one post, any state, with its text as the editor opens it, its media, its address and the digest base: hands back; the text comes back through add of a file saying edits: -- for a published post base: is required, and the save rebuilds and deploys | not_found, ambiguous_slug |
schedule <slug> --at <time> [--allow-partial] --json | the post, position in the queue, compacted (0). The time is YYYY-MM-DD HH:MM -- a T for the space, seconds and a zone (...Z, +02:00) if wanted -- or HH:MM alone for today; it is written down, and the post filed, in the site's own zone. Said any other way, or naming a day the calendar does not have, it is bad_date | schedule_needs_at, bad_date (a day without a time too), not_future, already_published, partial_translation (unless --allow-partial), busy, not_found, ambiguous_slug |
schedule <slug> --cancel [--compact] --json | the post back among the drafts, compacted: how many moved forward | not_scheduled, already_published, busy, not_found, ambiguous_slug |
publish <slug> --yes [--no-announce] [--allow-partial] [--compact] --json | the published post (add --json's shape), compacted; --no-announce puts it on the site and tells no network | busy, already_published, partial_translation (unless --allow-partial), publish_refused, not_found, ambiguous_slug |
unpublish <slug> --yes --json | the post as a draft, announcement_kept: addresses that could not be deleted | busy, already_draft, not_found, ambiguous_slug |
delete <slug> --yes [--rebuild] --json | trash: where it went, announcement_kept, deploy | busy, not_found, ambiguous_slug |
restore --json | trash: what is in it (slug, year, date, title, type, tags, state, media_only) | -- |
restore <slug> [--rebuild] --json | the post back in place | busy, not_found, ambiguous_slug, media_only |
rebuild [--full] [--force] --json | deploy: done, warnings | busy, rebuild_failed |
empty trash|versions [--yes] --json | count, bytes, size, emptied -- without --yes only the count, which is the question the terminal asks | empty_what |
check --json, stats --json | the findings, the figures -- see their own sections | -- |
on-this-day [--date <day>] --json | the day -- see its own section; --date is MM-DD, or YYYY-MM-DD to count the ages from another year | bad_date |
check --languages --json | the table of languages alone: languages (the site's own first) and rows (slug, year, title, cells: per language written, title_only or missing); empty on a site that publishes one language. Over a config/site.yml that cannot be read it refuses and exits 1 | config_missing, config_empty, config_syntax, config_unreadable |
doctor --json | the installation's diagnosis: errors, warnings, oks and findings (level: error, warn or ok; kind: the check it came from, such as trash, scheduler, deploy or banner; text; fix, null where there is no advice), problems first; no ok key, and the status is 1 when there is an error | refused with --strip-location (status 2, in prose) |
deploy in an answer is done when the site was rebuilt and deployed, pending when the rebuild was attempted and the next scheduled run owes it (the .deploy-pending marker), and skipped when the action does not rebuild unless asked and --rebuild was not given. The actions that rebuild without asking at the terminal -- publish, unpublish -- do so here too; the ones the terminal asks about -- delete, restore -- wait for the flag, so a program making three changes can rebuild once.
What a refusal's message says is the sentence the terminal would have printed, in the site's language; the error code is the same in every language and is what a program should switch on. A new code may appear in a later version; an existing one does not change meaning.
A command that writes a post -- props --set, --drop-address, --rename and --restore-version, schedule, publish, unpublish, delete, restore, toot, bluesky, a move in the queue -- answers busy while another run holds the archive: a build, the scheduler, another of these. Nothing was written, and the same request a moment later is the way through. (A text sent as a file is not among them: it says which version it edited and is refused as changed.)
An action on a post answers with the same nine keys whichever action it was -- ok, slug, path, state, scheduled, date (null for a plain draft), url, deploy, warnings -- and with what its row of the table adds. add of a file through a delivery answers that way too, for a new post, an edit and a translation alike. Two answers are about something other than the post and have a shape of their own: delete says where the post went (trash, announcement_kept, deploy, warnings), and a move in the queue answers with the queue.
One thing about types, for a consumer written in a language that has them: a post's year is a string wherever a post is named -- it is the folder the post is kept in, and what tells two posts of one slug apart -- and a number only in on-this-day, which counts with it.
Over SSH, from an app
A program on another machine -- the blog.sh app on a phone, a script -- reaches the engine over the SSH the server already has, on a key of its own with a forced command, the way the phone's shortcut does:
restrict,command="/path/to/blog/scripts/remote.sh" ssh-ed25519 AAAA... app
A request may also say which language its reader reads, {"args": ["doctor"], "lang": "cs"}. A blog speaks its own language, and an app in Czech showing an English blog's diagnosis would show English; with lang, check and doctor -- the two answers that are sentences for a person, and that build nothing -- are said in that language, if the engine ships it, and so is what pair itself refuses (device_unknown, busy). What the door refuses before the engine is run it says in English, as it always has. Every other command answers in the blog's language as before, because a command that may rebuild the site must not be handed another one. A language the engine does not have is not an error: the answer is then in the blog's own. The code is the bare one a locale file is named by, in lower case -- cs; what a phone calls its language (cs-CZ, cs_CZ, CS) is cut down to that by the sender, or it counts as a language the engine does not have.
scripts/remote.sh allows three words as the SSH command and nothing else. run runs one engine command: its argv arrives on standard input as one line of JSON, {"args": ["props", "venku", "--set", "tags=louka, les"]} and a newline -- the line ends the request, so a sender that cannot close its side of the stream is answered all the same -- word by word, so nothing is ever parsed as shell and a value with spaces or diacritics is one word on both ends. Before the engine sees it, scripts/remote.rb checks every word: the command has to be one a program may run (version, list, drafts, edit, translate, props, queue, schedule, publish, unpublish, delete, restore, rebuild, empty, toot, bluesky, stats, on-this-day, check, doctor, and pair for its list and the caller's own way out), each flag one that command has in its --json form, every positional word a slug, and no word may hold a control character or be longer than 4000 bytes. A slug here is a name as a file may have it and a reader would read it -- letters and digits of any alphabet, _, . and -, not starting with . or - -- which is wider than what the engine itself makes (lowercase, digits, hyphens) because an import keeps the names the old system gave: a post list hands out can be asked for by that name. --json is added when it is missing, so the answer is always an object -- and with it edit and translate open no editor: they hand the text out, and the changed text comes back as a file through deliver. What is not on the list is what asks a question, writes outside the archive or reaches a network on somebody else's account: add (a post arrives through receive), export, preview, browse, check --repair, doctor --online and --strip-location, the wizard -- and of pair everything but --list and --revoke with nothing after it: showing a code opens a way in, and a device named after --revoke is somebody else's (see "Letting an app in by a code"). receive is the delivery scripts/receive.sh takes -- pictures and a markdown, or a publish.txt or drafts.txt request -- for the same key. deliver is the same delivery for a sender that cannot close its side of the stream: it ends with a line saying end instead, which scripts/remote.rb reads up to and hands to receive.sh whole, so every check the receiver makes is made; a file may not be called end, and a delivery over twice the receiver's ceiling is dropped on the wire (too_large) before the receiver measures it exactly. version --json says the ceiling (max_mb), so a sender can weigh a delivery first.
Every answer to run is one object and the status is 0, for the reason the receiver gives. A delivery -- receive or deliver -- is answered as the receiver answers, with an object for each file, one after another: a line for every picture stored or refused, then each post that was written, printed over several lines. That is neither one JSON document nor a line apiece; a reader takes it object by object, by its braces.
A request that is not JSON, holds a word the whitelist refuses or is over 64 kB is refused as one (bad_json, bad_args, unknown_command, too_large), one that never arrives as empty_input, a connection that says none of the three words as no_command, and the engine answering in prose -- no env.sh, a configuration that will not parse -- is wrapped as engine_failed with its words: the end of what it said, or, where it died of an exception, the line that names the error -- and how it left, its status or the signal that ended it (a build killed for memory reads "ended by signal 9 (KILL)"). Two clocks answer timeout: the request's one line has BLOGSH_LINE_SECONDS (30) to end once it has begun, and an engine still running after BLOGSH_RUN_SECONDS (1800) is stopped with whatever it had started -- asked to end first, then ended, the whole of what it had run with it. A delivery through deliver is held to the same: the whole of it has the receiver's BLOGSH_BODY_SECONDS (600) from its first byte, however it trickles and wherever in a line it stops; it is measured against the ceiling as it comes, not line by line; and the receiver it is handed to has BLOGSH_RUN_SECONDS like any engine. Each of the three is a whole number of seconds, of eight digits at most, with spaces around it ignored; anything else -- a word, a zero, 30m -- is refused as bad_limit, naming the variable, rather than read as no time at all. An engine that a signal ended -- a build killed for memory on a small server -- is engine_failed naming the signal, to a request and to a delivery alike, and an answer is always valid UTF-8 whatever the engine printed. Unlike the shortcut's key this one may be ed25519; the forced command runs in the same environment receive.sh does, with the same BLOGSH_MAX_MB for deliveries.
Sending again. A connection can break after the request has gone and before its answer is back, and the sender cannot tell which side of the engine that was. What a second send does:
- A question --
version,list,props,queue,drafts,edit,translate,check,doctor,stats-- changes nothing and can be asked as often as it takes. publish,unpublish,delete,restoreandschedule --cancelare answered the second time by the state the first one left:already_published,already_draft,not_found,not_scheduled. That refusal is the news that the first one arrived.- A new post delivered twice is two posts -- unless its file carries
receipt:, a name the sender makes up once for the post (sixteen hexadecimal characters), and then the second delivery is the first one arriving again and updates the post it wrote (A delivery that arrives twice is one post). The line is the apps' as much as the page's, and stays when/write/goes. - An edit or a translation -- a file saying
edits:andbase:-- delivered twice ischangedthe second time:base:is the digest of the post's file, and the first delivery changed it. The answer to a save does not carry the new digest, soedit --json(ortranslate --json) is asked again before the next save, and after achangedwhose cause may have been the sender's own first try. - After
timeouton an action the engine was stopped where it stood, and the action may be half done: apublishwritten to the archive and not yet on the site answersalready_publishedfrom then on, while its address is a 404 until the site is built -- by the next scheduled run, which owes it, or byrebuild --json. Askpropswhat state the post is in, andrebuildif it is out.
The forced command runs in the account's non-interactive shell, which reads none of the files a terminal session reads -- so a Ruby that lives under rbenv or Homebrew is not on its PATH, and the engine refuses the system's old one. Put the path in the key's line, the way BLOGSH_MAX_MB goes there: command="env PATH=/home/me/.rbenv/shims:/usr/bin:/bin /path/to/blog/scripts/remote.sh". (env in front makes it a line every login shell runs: PATH=... script alone is sh's way of saying it, and an account whose shell is csh or tcsh takes it for a command's name.) A path with a space in it is single-quoted inside the double quotes; the app writes the line that way itself.
Where the blog lives in a container, the forced command is the one that enters it, and the word SSH was asked for -- which the script reads from SSH_ORIGINAL_COMMAND -- does not cross into the container by itself. Hand it over as the script's argument, which it takes the same way:
restrict,command="sudo docker exec -i blog /app/blog/scripts/remote.sh \"$SSH_ORIGINAL_COMMAND\"" ssh-ed25519 AAAA... app
-i is what carries the request in; the account needs to run that one command without a password. The app writes this line too, from the blog's place and the command it is reached through. Keep the quotes around $SSH_ORIGINAL_COMMAND: the word is handed on as one argument, and split into several, what the caller sent would be read as more than the command -- as the word a device's line says of itself, below.
Letting an app in by a code
Writing that line by hand stays what it is, and for whoever would rather not there is a second way beside it:
./blog.sh pair # shows a code and waits for the app
./blog.sh pair --list # the devices let in this way
./blog.sh pair --revoke 2 # takes one out: its number, name or fingerprint from --list
--list numbers the devices, and --revoke takes that number, the device's name (quotes or no quotes: --revoke motorola edge 70 fusion) or its fingerprint. With nothing after it, somebody at a terminal is asked which one, and nothing goes without a yes.
pair draws a QR code in the terminal (and prints the same thing as a line of text, for an app with no camera to point). The code wants a window of some sixty columns by thirty-five rows, which is more than a terminal opens with, so a window too small is asked to grow first; one that does not is told how large the code is, and the line of text is there either way. The app reads it and is connected; nobody types an address, a user name or a key. What the code holds is where to connect -- the address, the port, the account -- the fingerprints of this server's own SSH keys, so the app knows it reached this machine, and a key that is good for one thing, once, for ten minutes: handing in the public half of a key the app made itself. The engine then replaces the code's line in ~/.ssh/authorized_keys with an ordinary one for the app's key, held to scripts/remote.sh like a line written by hand. The key the app lives on never leaves the device, and the one that was on the screen opens nothing once it has been used.
A device's line says one thing a line written by hand does not: whose it is. Its command ends .../scripts/remote.sh' --device=<fingerprint>, the fingerprint of the key on that same line. sshd tells a forced command nothing about the key that was used, so that word is how one device is told from another -- and the only thing it is told by: not by anything a request says, and not by the environment a session arrives with. A device let in before lines said this gets the word the next time ./blog.sh pair is run at a terminal -- --list is enough -- and nothing else on its line moves. (By the account itself: under sudo the lines are left as they are, because a file replaced by root is root's, and sshd reads it as the account.)
What pair does to authorized_keys, since that is the file that decides who may log in:
- It writes only lines that begin
restrict,and whosecommand=names a script of this installation:restrict,expiry-time="...",command=for a code that waits,restrict,command=for a device. What an app hands in is taken only if it is exactly one ed25519 public key. - Where
authorized_keysis a link -- kept with your dotfiles, say -- the link stays a link and the file it points at is the one rewritten. The file keeps who may read it (one you made read-only stays so) and is never left writable by anybody else. - It touches only lines it wrote itself, known by the comment that ends them (
blog.sh:<device>, andblog.sh-pair:<id>while a code waits) together with this installation's path. Every other line -- a key of your own, another blog's devices -- is written back as it was. - The file as it was before each change is kept beside it as
authorized_keys.blog-sh.bak. - With a code closed again, or a device come and gone, the file is byte for byte what it was: carriage returns on lines that are not
pair's stay, and so does a last line with no line feed after it. pair --listonly reads: it makes no folder and takes no lock unless there is a run-out code's line to take out, or a device's line from before lines said whose they are to bring up to date. Asked through the door it writes nothing at all.- A line names this installation by its folder. Move the folder and its devices' lines still name the old one: sshd finds no script there, the apps are refused, and neither
pair --listnordoctorsees the lines any more, because they are no longer this installation's. Take them out ofauthorized_keysby hand (they endblog.sh:<device>) and pair the devices again from the new place.
The ten minutes are kept by the engine: the code's key can do nothing but ask it, and it refuses a key that arrives late. The code's line also carries sshd's own expiry-time as a second lock; that one is coarse (sshd reads it to the minute, and in summer time an hour late), so a line nobody came for may sit in the file that much longer before sshd itself refuses it. The next ./blog.sh pair, of any kind, removes it, and doctor mentions one that is left.
It needs the blog and the SSH server in the same account: a plain server, or a Mac with Remote Login turned on. pair checks that SSH answers before it makes a code, and asks for the one thing it cannot know -- the address this machine has from where the phone is. What it offers is the machine's number on its network, which a phone on the same network can always use; a name works where the phone can look it up (--host and --port say it without being asked; --no-wait prints the code and leaves). Where the blog lives in a container the keys are the host's and pair cannot reach them; that setup is written by hand, as above. BLOG_SH_AUTHORIZED_KEYS names the file for an account whose sshd reads another one. Write it into env.sh: pair and doctor both read it there, so the list of devices and the diagnosis are about the file the code was written into. Said only in a terminal it is gone with the terminal, and the next pair --list looks into the account's default file and finds nobody. What sshd itself is set to read the engine cannot see; that the two agree is yours to check (sshd -T | grep -i authorizedkeysfile, as root).
For whoever writes an app: the code is a link, blogsh://pair?v=1&h=<host>&p=<port>&u=<user>&k=<key>&f=<prints>&n=<site>, its values written as a web form writes them -- a space in the site's name is a +, and a + would be %2B. k is the 32 bytes of an ed25519 private key in URL-safe base64 with no padding; f is the SHA-256 fingerprints of the server's host keys in the same alphabet, joined by dots, and may be absent. Connect with that key, refuse a server whose key is not among the fingerprints, ask for the command enroll and write one line of JSON, {"key": "ssh-ed25519 AAAA...", "name": "the device's name"}. The answer is one object: {"ok": true, "device": "..."}, or ok: false with error one of expired, used, unknown_code, bad_key, bad_request -- and, when it is the machine rather than the code that is at fault, engine_failed (with the engine's words in message), no_ruby, no_cd or unknown_command. key_in_use is a key that already stands on another line of the account's file -- another blog's, or one written by hand: sshd runs the first line a key is on and never a second, so an app makes one key for each blog it connects to. From then on the app's own key works as any app's does.
A device let in this way can show the others and take itself out. Both are run requests like any other:
{"args": ["pair", "--list"]}
{"ok": true,
"devices": [{"name": "Daniel's iPhone", "fingerprint": "SHA256:k/JB...QCo", "self": true},
{"name": "Tablet", "fingerprint": "SHA256:wL8H...u+w", "self": false}],
"self": "SHA256:k/JB...QCo"}
{"args": ["pair", "--revoke"]}
{"ok": true,
"revoked": {"name": "Daniel's iPhone", "fingerprint": "SHA256:k/JB...QCo"},
"devices": [{"name": "Tablet", "fingerprint": "SHA256:wL8H...u+w", "self": false}],
"self": null}
devices are in the order pair --list numbers them at the terminal, and a fingerprint is written as ssh-keygen -l writes it: SHA256: and 43 characters of base64 with no padding (an empty string for a line whose key somebody's hand has left unreadable). self -- on each device, and once beside the list -- says which of them is asking; it is null, and every device's is false, for a key whose line does not say whose it is: one written by hand, or one of a pairing older than the word, until ./blog.sh pair has been run at the terminal once -- and where the engine reads another key file than sshd does (BLOG_SH_AUTHORIZED_KEYS belongs in env.sh; one a session arrives with is not listened to). An app shows the list all the same and offers no way out.
--revoke takes out the device that is asking and no other; nothing may follow it. The request is answered -- sshd does not end a session because the key's line has gone -- and the next connection with that key is refused by sshd itself, so an app forgets the blog when it reads ok: true. (Every line of this blog that holds the key goes, should there be more than one. A line that is not this blog's -- one written by hand, another blog's -- stays, and a key that stands on one still opens what that line opens.) The refusals: device_unknown when the blog cannot tell who is asking (self was null), busy when somebody else is changing the file at that moment (nothing was taken out; ask again), write_failed and read_failed when the file could not be written or read, keys_path when BLOG_SH_AUTHORIZED_KEYS names a path that cannot be expanded, and bad_args for anything else after pair -- a code, an address, a name. Taking out ANOTHER device is done at the terminal, by whoever can sit at it: ./blog.sh pair --revoke. A blog whose app was set up by hand answers --list with no devices, since none came in by a code.
