Skip to content

feat(console): let a guild say where its protocols should go - #150

Merged
TheMeinerLP merged 1 commit into
mainfrom
feat/console-export-targets
Aug 23, 2026
Merged

feat(console): let a guild say where its protocols should go#150
TheMeinerLP merged 1 commit into
mainfrom
feat/console-export-targets

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Puts the export destinations on a page of their own, gives the credential a control that cannot silently clear it, and turns the recording page's single "Open the protocol" button into the list of where a meeting actually went.


The problem

A guild can publish to several destinations in several formats. GET/POST/PUT/DELETE on /api/guilds/{id}/export-targets have been live since #144, session_document records one row per destination that succeeded, and none of it is reachable from the interface. What an administrator sees is document_target on Bot Settings — a single Outline collection id in a text field — which now is not the answer but the fallback for a guild that has configured nothing else. destinations_for replaces it with the configured destinations rather than joining them, so a guild that switches one destination on here silently stops using that setting, and nothing anywhere said so.

The recording page has the mirror of the same problem. It renders session.document_url as one link, and that field is stamped from the primary destination alone. A meeting that reached two destinations out of three shows one button and reads as finished.

The page

/admin/destinations, in the Admin View, second — beside the configuration it used to be part of, so the two do not read as rival settings. Built out of #133's controls: UiSelect for the guild switcher and both pickers, UiDisclosureList for the rows, UiPagination under them.

Every decision is in app/utils/exportTargets.ts and tested without rendering anything; the page is layout, request plumbing, and which panel is open. The guild switcher is not reimplemented either — it is the same chooseGuild/readSelectedGuild Bot Settings and the Queue use, remembered choice included.

The order is by id, and that is not cosmetic. destinations_for sorts by id precisely so the primary does not move when somebody renames one, and the primary is the link the Discord announcement carries. So the list is ordered the way it publishes, the first enabled row wears an Announced in Discord badge, and a sentence above the list says why. Sorting alphabetically would have shown a first row that is not the first destination — the one fact on this page with a consequence outside it.

pdf and confluence are absent, not disabled

They are specified and deliberately not built (export_formats, spec §3.4), and configuring one answers 400 with {"supported": [...]}. The console has two precedents for an option it cannot honour and they point opposite ways, so this had to be argued rather than picked.

The account menu renders two-factor authentication as an inert coming soon row. That is right there: it is a promise to the reader about their own account, sitting in a menu of things to read, and an absent row would read as an oversight.

video_consent_offered goes the other way. When a guild has no video policy the option is absent — not greyed — and one sentence beside the control says the server records audio only.

This is the second case, twice over.

It is a form field. A dropdown exists to be chosen from and saved. A row inside one that a save would refuse is not a promise; it is a trap under the cursor of somebody deciding, and the lesson "this console's controls do not all work" is learned once and applied to every control afterwards.

And more decisively: the console cannot see the deployment's registry. supported_formats() lives in the API process, no endpoint reads it out, and apiError.sanitiseFetchError deliberately keeps nothing of a failed response but its status — so the supported list a 400 carries never reaches a page, and cannot without undoing the reason that module exists. A PDF — coming soon row would therefore be this console asserting a fact about a build it cannot inspect. Worse: export_formats promises that adding pdf to that registry needs no change anywhere else, and the console would go on calling it unavailable until somebody remembered to edit a second list.

So EXPORT_FORMATS is stated as a belief about the deployment, and the console defers to the stored value whenever the two disagree:

  • A destination already storing a format this console has never heard of keeps it. The picker offers it as a fourth row labelled with its own raw name, so that renaming such a destination cannot rewrite it to outlinedirectory.ts's rule for an unresolved channel id, applied to a word instead of a snowflake.
  • primaryTarget does not filter on the format list. Whether this deployment can publish pdf is the deployment's answer, and skipping a row because the console does not know the word would name the wrong destination as the announced one on exactly the deployment where it matters.
  • One sentence under the picker says which formats this deployment publishes, so their absence reads as a fact rather than as a list that failed to render.

The credential

The API's shape is the argument, and this follows it rather than working around it. ExportTarget has has_secret and nothing else; ExportTargetStore.secret_for is deliberately not on the port a console handler can reach; PUT .../secret is a route of its own because a PUT on the destination that also wrote the credential would clear it on every rename.

There is no credential field on the destination form, and TargetDraft has no field for one. The mistake is unrepresentable rather than merely avoided: there is nothing a careless v-model could bind to.

ExportTargetSecret is the control that writes it, and it does four things:

  • Says only that one is stored. No reveal, and no mask either — a masked value is a value: it says how long the token is and what it starts with, and it promises a "show" button this API cannot serve. One sentence says there is no way to read a stored credential back, because there is not.
  • The input does not exist until somebody presses for it. An empty password box rendered beside a configured credential is an invitation to save the form and wipe it, which is the exact failure the API split this onto its own route to prevent. Closing the panel discards what was typed; nothing half-typed lingers in a hidden input.
  • Clearing is its own act, confirmed, and told what it costs. {"secret": null} is a different request, and it is irreversible in the strongest sense available — nothing anywhere can read back what was there to put it back. An empty box is refused on submit, so "save nothing" cannot read as a way to clear one.
  • Says that nothing reads it yet. None of the three built formats consumes a stored credential: Outline publishes with the deployment's own token and the two file formats are written into the deployment's own store. secret_for exists and no publish path calls it. A control that let somebody believe their token was in use would be a control that quietly does nothing, so it says so — and it is still rendered on every destination, because has_secret is a real state and a control that hid itself would leave a stored credential nobody could clear.

What each format needs

The form shows what this format needs rather than the union of every field.

Format Address Control
outline an Outline collection id the picker Bot Settings already has for document_target, over GET /api/outline/collections
markdown an object-store key prefix a text box; there is no directory to browse
html the same the same

Which of the two a format wants is FormatSpec.targetKind, so adding a format stays adding an entry rather than a branch in a template. Both address patterns are the API's own target_pattern, anchored the way fullmatch anchors them — a second copy of a rule, and a courtesy rather than a control in exactly the sense navigation.ts means it: the API refuses regardless, and what this buys is that the reason is legible beside the field instead of arriving as a bare 400 the console has stripped of its explanation.

The collections endpoint is decoration, as it is on Bot Settings: if it refuses, the field falls back to asking for a collection id and one sentence says why. A configuration page that cannot be used because a name list is down would be worse than one that asks for ids. A stored id with no row in the copy stays in the list rather than being dropped.

config is carried through untouched and never edited. No built format needs anything in it, and a JSON box on a page nobody has a reason to type JSON into is a way to break a destination rather than to configure one.

A name cannot be changed here, because the API cannot change one. update_target reads the name from the stored row and ignores the body, so that "publish to Wiki" cannot stop meaning what the person who set it up thought it meant. An editable box over a value that will be discarded is the interface promising something the API refuses, so on an edit the name is read-only and one sentence says that renaming is adding a destination and removing this one.

Where a meeting actually went

On the recording page's The meeting tab, in a section of its own. The listing is fetched alongside the session and the transcript, swallowed the same way — and as null rather than [], because "nothing was published" and "where this went could not be read" are different answers and drawing the second as the first would tell somebody their meeting reached nowhere when in fact nobody asked.

A participant cannot count a guild's destinations — that listing is administrator-only and answers 404 to them — so this page can never say "one of three failed". What it can see is a disagreement between two answers, and one of them is exact: document_url is stamped from the primary alone, and the listing carries every destination that succeeded. So:

document_url listing reads as
none empty no protocol has been written yet
set empty the legacy document_target publish, which records no row — every meeting from before destinations existed. One link, and a sentence saying why there is nothing to list
set contains it published to n destinations, each with its format and its date. No caveat
none non-empty the destination Discord announces produced nothing while these did
set does not contain it the announced link is not one of the destinations below

The fourth row is the partial publish, and it is a fact rather than an inference: publish_session survives each destination's own failure and records only the ones that succeeded.

Nothing diagnoses. These sentences say which two answers disagree and stop. They never call a destination broken, because a destination the retry sweep has not reached yet and one that will never work look identical from here — the discipline utils/queue.ts already applies to its derived figures, and there is a test asserting the words failed, broken and error appear in none of them.

A row whose destination has since been removed keeps its link and says so: target_id is ON DELETE SET NULL because removing a destination is "stop publishing here", never "forget what was published".

document_target, said where it will be read

The scope asked for this to be stated where somebody configuring destinations will read it, and that is where the weight is: a section at the foot of the page whose sentence is a function of that guild's state, because the fallback is genuinely in force for one guild and genuinely dead for the next. Nothing enabled → it still publishes where document_target points, and switching one on here takes over completely. Something enabled → that setting is no longer used for it, and these replace it rather than adding to it. A link to Bot Settings under both.

The reciprocal note goes on Bot Settings, through fieldHints, which is where that page already puts what a key means. It is one entry in a table rather than a branch, and it is English like the rest of that module's hints for the reason those are — that page awaits the sweep i18n/README.md describes. It cannot say which of the two is in force, because a hint under a settings key knows nothing about that guild's destinations; the Destinations page can, and does.

Untouched

No Python. The queue page, the consents page and recordings/index.vue are not opened. Guild onboarding and OAuth configuration belong to sibling branches. The recording page's tabs, its transport, its tag editor and its re-queue panel are unchanged — the protocol link grew into a section beside the "About this meeting" one, and nothing else on that tab moved.

New strings go through i18n in both locale files under admin.destinations.*, complete from the start because the page is new. Three of them are not there: a format's name lives in common.*, because the recording page has to say the same three words beside a published document and two copies of a word are two words that drift. test/i18n.spec.ts's allowlist grows by two — Name and Format, which German borrowed unchanged — each with its reason.

Pure Tailwind, no <style> block, colours from role tokens, one animate-pulse skeleton carrying motion-reduce:animate-none.

Checks

vitest run 1657 passed, 53 files (+79 tests, +2 files over main's 1578/51) · lint · typecheck · build — all clean, measured on the branch cut from main.

A guild has been able to publish to several destinations in several
formats since the export-target API landed, and none of it was reachable
from the interface: `document_target` on Bot Settings still looked like
the only answer, when it is now the fallback for a guild that configures
nothing else.

Adds a Destinations page to the Admin View over the CRUD routes, a
write-only credential control, and — on the recording page — the list of
what one meeting was actually published as.

The decisions live in two pure modules and are tested without mounting
anything: which formats are offered and why `pdf` and `confluence` are
absent rather than disabled, what each format will accept as an address,
which destination Discord announces, what may be done to a credential,
and how a partly-published meeting reads.
@TheMeinerLP
TheMeinerLP merged commit 364eb29 into main Aug 23, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant