The pipeline was hard-coded to Simplified Chinese in three places: the
drafter was told to translate into Chinese, the critic was told to compare
English with Chinese, and the glossary's translated forms were read from a
"zh" key. A production that declared target_language: es-ES was therefore
translated into Chinese and filed under a Spanish name - measured on oldbox,
deal-hunter-es held 3,038 paragraphs of Chinese and was reported complete.
A new registry, tools/translation/languages.py, is the single source of
truth for what a target language is: its names, its script, its punctuation,
its glossary key, its typography and its chapter-numbering convention. Ten
languages are registered. The prompt scaffolding stays in Chinese - that is
what the models read best - and the target is named with its Chinese, English
and native names so there is no room to misread it.
The registry now reaches every stage:
- prompts.py: the analyst, drafter, critic and glossary blocks take the
target. The hard rules about this book - Deal Hunter is the ship's name,
the AI's name and the book's title, so it stays in English - are the
language's own table for Chinese and the global rules for everything else,
so a language with no table does not lose them. The Chinese rules are
byte-for-byte what they were.
- pipeline.py: --target-language, a run-directory guard that refuses to
reuse a directory belonging to another language (from the manifest, or by
measuring the text it already holds), a draft-keyed-by-target reader, and
a refusal of a draft that came back in the wrong script.
- book.py: per-language chapter-title and subtitle tables. A language with no
table falls back to the English title, never to another language's; a
language with a table and a missing chapter gets a visible marker in its
own language.
- qa.py: the target's quote marks, punctuation, script count and forbidden
title forms. The Latin-residue check is skipped for a Latin target, where
it would flag the whole book.
- align.py: the aligner and verifier prompts name the target, and the
sentence and clause splitting uses the target's own terminators.
- render.py: the fonts, the language and country codes, the writing
direction, the chapter headings and the fixed editorial strings around the
translation are the target's. The Latin-run span is skipped for a Latin
target, where marking the text as English would tell the renderer it is
English.
- project_status.py: the dashboard measures a sample of a production's text
and reports a production whose text is not in the language it declares as
"mismatched", not "complete". A chapter count is a claim about the files;
the script check is a measurement of the text.
Also fixes: the dashboard gated every language's production on the source
production existing, so a Spanish production could never be reported even
when it was there.
The Chinese render is byte-identical to before on the real book, and the
Chinese rules are unchanged. Suite: 620 passed, the same 68 pre-existing
environment failures, +49 new tests.
Add REST endpoints and frontend wiring for per-language project actions:
- POST /api/workspace/{project}/translation/{lang}/queue — queue translation
- POST /api/workspace/{project}/audio/{lang}/queue — queue audio generation
- GET/POST /api/workspace/{project}/format/{lang} — format metadata (EPUB/PDF)
- GET /api/workspace/{project}/review/{lang}/chapters/{chapter} — utterance review
- GET/POST /api/workspace/{project}/front-matter/{lang} — front matter editor
- GET /api/workspace/{project}/chapters/{lang} — chapter list
- GET /api/workspace/{project}/chapter-content/{lang}/{chapter} — chapter text
Frontend fixes:
- Review page shows per-language text (English source, Chinese translation, etc.)
- Non-translated languages show 'Queue translation' button only
- Speaker data from English source matched by paragraph ID
- UmbraAudio column layout: Speaker, Variation, Text, Actions
- Chapter tree in left sidebar: Front matter, Back matter, Body
- Audio Format label at top of sidebar
- Pandoc formatting toolbar for front matter text editing
The 720px breakpoint contained three rules:
.admin-sidebar { display: none; }
.rail-link:not(.rail-link--active) { display: none; }
.topbar-admin-link { display: none; }
Every one hides navigation rather than relocating it. On a phone the console showed the
page you were on and no way to reach any other: the admin sidebar was gone, every rail
link except the current one was gone, and the route from the workspace to the control
panel was gone. There was no hamburger to find because nothing had ever been built for a
narrow screen - the breakpoint assumed the sidebar could simply go away, which is only true
when nothing else is left to navigate with.
A toggle appears below the breakpoint and reveals the navigation as a drawer. The drawer is
positioned rather than shown inline, because an inline nav pushes the page down by its own
height every time it opens, so on a short viewport the page the operator asked for ends up
below the fold. Escape closes it, a click on the page behind it closes it, following a link
closes it, and crossing the breakpoint settles the state - without which the drawer class
survives into the wide layout and the bug only reappears when the phone is rotated back.
Above the breakpoint the toggle is hidden, because a control that opens what is already
open misreports the state of the page.
nodes.html is deliberately not given a drawer. Its navigation is inline in <main> rather
than in a sidebar, so there is nothing to put in a drawer and a hamburger there would
toggle nothing; its nav was a single horizontal scroll strip instead, which hides links off
the right edge with no sign that there are more, so it now wraps.
test_navigation.py states the rule - navigation hidden at a width must still be reachable
at that width - and reads the breakpoint with comments stripped. Two things that check got
wrong on the way, both recorded in the file:
- It flagged the drawer's own "display: none" as a violation. Hiding a closed drawer is
correct, or it would push the page down on every load; what is wrong is hiding navigation
with no way to open it. The test now requires a "body.nav-open" rule alongside any hide.
- It only required that *some* page carry a toggle, so deleting it from the admin console
- the page that was reported broken - passed, because the workspace still had one. Now
per page.
Mutation-verified: restoring the original defect fails 1, removing the toggle from the
admin console fails 1.
695 passed, 4 skipped.
The hint said to run 'sudo visudo' as though the account running the command already had
sudo. On a machine set up the way this one was - a separate administrator, then a
daily-use account - the daily-use account is not in the sudo group, so that instruction
could not be followed by the person who needed it. Replaced with the two steps that
actually apply, in order:
1. The account running the command must be in the sudo group. Check with 'id -nG'; if it
is not there, an administrator adds it with 'usermod -aG sudo <account>' and the
account signs out and back in, because group membership is fixed at sign-in. This is
the step that was missing, and it is the one that cannot be done by the person stuck.
2. Passwordless sudo, from an account that already has sudo: 'sudo visudo -f
/etc/sudoers.d/umbra-node', add the NOPASSWD line, save. Optional - without it the
command still works and simply prompts.
Written after a run stopped at a sudo password prompt with nothing on screen but 'Sorry,
try again.', which is sudo's own message and appears nowhere in this codebase, so the
failure looked like the system refusing rather than the shell asking.
Checked against 238: umbra is already in the sudo group, so only step 2 applies there.
The commit immediately before this one - the password-toggle fix - deleted a closing
brace and did not replace it. app.js did not parse. The browser reported it in a console
nobody was watching, and every script-driven behaviour on the sign-in page stopped.
The consequence was not a cosmetic one. The login form's submit handler, which is the
only thing calling preventDefault(), never ran, so the browser performed a native form
submission. Neither form declared a method, and a form with no method attribute submits
as GET. Both password fields went into the query string:
GET /?email=umbra%40bdmurphy.me&password=...
The server logged it in plain text, three times, before it was noticed. It would also
have sat in browser history and in any proxy access log between here and the public
internet.
Two fixes, the second of which is the one that matters:
- The toggle fix is reapplied correctly to the last known-good version of app.js, with
nothing else in the file touched - the diff is four lines against thirteen and two
against one.
- **Every form on every page now declares method=post.** The script still intercepts
and submits via fetch; this decides what happens when the script does not run, which is
precisely the case that failed. Five more forms carried the same latent defect,
including password reset and email verification.
test_form_safety.py checks both, and was confirmed to fail when app.js is broken the
same way. It uses esprima rather than a hand-written brace counter, which gave two false
readings during this work - one of them 'balanced' for a file that did not parse at all.
A check that can be wrong about a syntax error is worse than no check.
The account password should be treated as exposed and changed. So should any password
entered into that form while the script was broken.
The variable was called 'reveal' and held `input.type === 'password'` - which is true
when the password is HIDDEN - and was then used to pick the label. So a masked field
offered 'Hide', and clicking it revealed the password and relabelled itself 'Show'. The
click handler inverted the same way, so the two errors cancelled and the visible result
was a control that appeared to work while doing the opposite of what it said.
Renamed to 'visible', which is what the value now means. A correct-looking name is not
what prevents this; the value and the name agreeing is.
Affects the login field and both fields on the change-password screen.
Verified by running the two states rather than by reading the edit: masked says 'Show',
plain says 'Hide', and two clicks return to the start.
Three navigation faults, all found from the service request log rather than by looking
at the page:
- The console had no link to the nodes page at all. It was a standalone file I created
and never wired in, so leaving the nodes page meant there was no way back except the
browser. It is in the nav now, and every href on the console was checked against the
running service rather than assumed.
- VIEWS omitted 'projects' although the section and its nav link both exist, so clicking
Projects silently fell through to the dashboard. A link that looks live and does
nothing is worse than no link. Flagged in an earlier session as not-my-problem and left
alone; it was two characters.
- A 401 left the nodes page sitting there saying the session had ended, with nothing on
the page that could work without one. It now redirects to the console, which is what
the console does itself.
The empty organization dropdown was this too: with no session the organizations request
is 401, the list is empty, and the form is disabled with the new banner - which is
correct behaviour for a genuine no-organizations system and a misleading one for an
expired session. Redirecting first is what tells those two apart at the point where it
matters.
547 passed, 29 skipped.
The organization dropdown rendered empty. Three separate defects behind it, found from
the service's own request log rather than by looking at the page:
- /api/uadmin/organizations returned 200 - the data was arriving - while
/api/compute/nodes returned 422 on every load. ORGANIZATION_ID was only set when the
account could see exactly one organization, so for anyone who can see several the node
list was requested with no organization and the compute routes refused, as they are
designed to. The list is now fetched when a choice is made, not at load.
- The page had no case for *no* organizations. A node belongs to exactly one, so there is
nowhere to put it: the form is now disabled and a banner explains why, with a link to
the organizations screen. A dropdown with nothing in it and no explanation is the same
as a broken page.
- A 401 - an expired session - was indistinguishable from an empty list. It now says the
session ended, which is the one failure the operator can act on.
Also removed eight CSS classes I had invented rather than looked up: card, card-body,
card-note, field, form-actions, table-wrap, alert--warn and button--quiet. None exist in
styles.css, so the page was rendering unstyled. Replaced with what the working console
page uses - admin-card, form-field, modal-actions, admin-table-wrap, admin-banner--
warning, button--ghost. Every class on the page is now verified present in the stylesheet
rather than assumed, and the markup is checked as balanced by a parser rather than by
counting tags by eye.
0005 was applied to the deployment to unblock startup: the tenant-schema contract check
runs at boot and correctly refused to start a service whose schema it could not find. That
is the check working. Backup taken and checksum-verified first; three tenant schemas
created, one per organization, with users=2 orgs=3 nodes=9 audit=46 unchanged.
547 passed, 29 skipped.
It described the control as a hint whose effect was decided at run time, which was
true and misleading: the paths are chosen here, at render time, and the command
follows.
Feedback on the add-node page: the command was hard to read and hard to copy.
Both the command and the six-digit code now use .otp-value-row / .otp-value - the
wide monospace value with its copy button beside it that the provisioning dialog
already uses. Reusing that rather than writing new CSS, because a second styling of
the same idea is how the two drift apart.
The command was a <textarea> carrying a class named `code-block` that exists nowhere
in the stylesheet - I had invented it - so it rendered as a default textarea rather
than the monospace block it was meant to be. It is now a <code>, which means it is
written with setText rather than .value; setting .value on a <code> silently does
nothing, which is the same class of silent failure as the missing toast element.
The standalone "Copy the command" and "Copy the code" buttons at the bottom are gone:
each value now has its own button next to it, so there is one obvious way to copy each
thing and no need to match a button to a field.
Done is now labelled to say what it does - "clear this from the page" - because the
code is only in the page's memory and the controller cannot produce it again.
Reported as "I entered info, clicked create, and nothing happened". Three separate
faults, each of which fails quietly, so the page looked like it simply did not work.
**1. The toast element had the wrong id.** api.js's showToast() looks for
`id="toast"` and returns silently when it is absent. The page had
`id="toast-region"`. Every message - success and failure alike - was appended to
nothing. This is the direct cause of "nothing happened": the request may well have
succeeded and the answer was thrown away. The working console page uses id="toast";
mine should have matched it.
**2. Seven `el()` calls passed a className where api.js wants a props object.**
`el(tag, props, children)` assigns `props.class`. `el('td', 'table-note')` therefore
set an attribute literally named "table-note" and no class at all, so the node list
would have rendered unstyled. Caught by reading the helper instead of assuming its
signature from the name.
**3. A platform administrator could not use the page at all.** The compute routes
refuse to infer an organization - they raise `organization_required` and ask for
`?organization_id=...`. The first version read an `organization_id` out of
/api/uadmin/summary, which has no such field, so it was always empty and every create
was a 422.
The page now loads organizations from /api/uadmin/organizations and presents a picker,
sending the chosen id as a **query parameter**. It went in the JSON body at first,
where the route declares it as a Query - a body field of the same name is silently
ignored, which looks exactly like it worked. An organization administrator has exactly
one and sees it preselected rather than being asked to choose.
Also added `defer` to both script tags, as the working console page has, so api.js is
defined before nodes.js boots.
Verified the corrected request path end to end: the same payload the browser now sends
produces a code, and that code pairs a node on 238 through the `curl | sh` flow.
525 passed, 27 skipped.
The page an administrator uses to bring a node online: name it, get a one-line command
and a six-digit code, hand both to whoever is at the machine. That is the last piece
between "someone runs a command" and self-service onboarding.
POST /api/compute/pairing-script now returns JSON - script, code, expiry and the
command - rather than the bare script. There are two callers with two needs: a person at
a terminal wants a script to pipe to sh, and the console wants all three values so it can
show them once and then forget them. Returning text would have forced the console to
parse a shell script to recover a six-digit number, which works until someone reformats
the script.
The code is shown once and there is no button to fetch it again, because the controller
keeps only an Argon2id hash. That is the server's limit, not a UI choice, and offering a
"show code" button would be a lie about what it can do.
## Three defects the template flow exposed
Running `curl ... | sh` on node 238 - the flow the console hands out - found that the
template could never pair, for three separate reasons:
- It had no prompt and no code. `000000` is the placeholder that means "nobody filled
this in", so the script now asks. A rendered script from the console has the code
substituted and skips the prompt, so nobody is asked for a number they do not need.
- It had no node name, and the name is what the code is bound to. It now asks too. The
first version failed with "check the code has not expired" while the code was fine and
the *name* was the placeholder - an error pointing at the wrong thing.
- It hardcoded `controller.invalid` as its controller address, so every deployment but
one got a script that could not resolve its own controller. The address now comes from
the Host header the template was fetched with, and `UMBRA_CONTROLLER` overrides it.
The error message itself was part of the problem: a DNS failure reported "check the code
has not expired", which is a worse fault than no message because it sends an operator to
check the one thing that was working.
## Verified
`curl -fsSL http://192.168.123.14:8442/api/node/bootstrap | sh` on 238, code and name
typed at the prompts: paired, agent installed, service registered, node `online`.
`/api/node/bootstrap` is unauthenticated and returns only the inert template - a script
that can check a machine and then stop. It is listed by name in the test that pins
unauthenticated node routes, alongside /pair, with the reason each is safe.
## Also found, not fixed
`VIEWS` in web/assets/admin.js is `['dashboard','accounts','organizations','audit']` but
`view-projects` exists in the markup, so the projects view can never be activated. It
predates this work and I have left it alone rather than change navigation behaviour as a
side effect of adding a page.
525 passed, 27 skipped.
An organization's work is separate from every other organization's, and the
separation is now one module rather than a property each route remembers.
`tenancy.py` resolves the caller's scope in a single place. A member is pinned
to the organization on their own assignment, expressed as a concrete identifier
rather than a flag, so "no filter" is unreachable except for a platform
administrator. Every read and write resolves through it, so there is no per-route
variation to drift.
Within an organization, every account sees every project. The brief is explicit
that two people working on different books both see both, and assignment says
who is *working* on a project rather than who may *know* it exists. This
diverges from `app.can_access_project`, which restricts visibility to assigned
members; the divergence, and what it means if row-level security is ever
switched on, are recorded in migrations/README.md because that would otherwise
be a silent tenant-internal regression no test here would catch.
Two rules, both learned by getting them wrong first:
- A read naming another organization is ignored, because refusing it would
confirm the identifier exists. A write is refused rather than retargeted,
because a project landing in an organization the caller did not ask for is
somewhere they will not look.
- A missing project and a foreign one get the same error, so an identifier cannot
be probed across a boundary.
Projects: the `projects` table, its statuses, and the project-scoped arm of
`user_roles` were already in the schema and unused. No migration.
The dashboard is one server-scoped call, `/api/workspace`, rather than several
assembled in the browser — pieces from different tenants that nobody notices are
not a failure mode worth shipping. Filters narrow the list already in the page,
so switching cannot mix two fetches, and an empty list is distinguishable from a
failed one.
**A sign-in lockout, found by the integration suite.** An assignment is a second
active `user_roles` row, and `_resolve_assignment` refused an account with more
than one. So assigning anybody to anything made them unable to authenticate —
locked out of the page the assignment was meant to show them. `_sync_assignment`
had the same defect. Six queries were scope-blind; three were harmless only
because the assignment role is named `viewer` and never matched a tenant role
name, which is luck rather than a property, so a structural test now pins the
predicate on every one. The guard against two *tenant* rows survives, narrowed to
the ambiguity that actually matters.
Also fixed: `get_project_by_slug` cast `slug::text`, making citext's
case-insensitivity a case-*sensitive* comparison that appeared to work everywhere
else. An assignment grants the `viewer` project role, since no `member` role
exists at project scope; it is a named constant, not a query literal, because the
project workflow is not defined yet.
410 tests pass with the integration suite against a real database; 386 without.
The accounts page now has an Email column carrying the address and its
verification state. A confirmed address shows a plain marker; an
unconfirmed one shows a button that sends another confirmation link.
The field was already on the response, and it was lying.
AccountResponse.is_email_verified defaults to True and from_view never set
it, so every account in the list reported itself as confirmed, invited
ones included. A badge that always reads "Confirmed" is worse than no
badge, and about to hang a re-send control off it made that worth fixing
first. It is passed explicitly now, with a test covering an invited
account.
The address moves out of the Account cell into its own column, because the
verification state is a fact about the address and reads better next to
it than stacked under a name.
The badge is a button only when unverified. Offering to re-send a
confirmation for an address that is already confirmed would imply a
pending one might exist, and would put a live control on every row for no
reason.
The re-send posts to the endpoint the sign-in page already uses, which
answers identically whether or not an account is awaiting confirmation.
That is deliberate: the console holds a list of accounts, so "is this
address verified?" is exactly the question that should not be askable of
an arbitrary address, and an administrator-specific endpoint would answer
it precisely. A test asserts the two responses are byte-identical.
Verified end to end against a scratch database: an invited account
renders as "Not confirmed", the control sends a verification link rather
than a reset link, and an address with no account produces the same
message.
The reset link was reported as "no longer valid" while it was still live.
The token in the store was unconsumed and had not expired, so the link was
fine and the page was wrong.
Two mistakes, and the second is the one that mattered.
The first was a double question mark. passwordResetAvailable did
'/api/auth/reset-password?' + buildQuery(...), and buildQuery already
returns a leading '?', so the request went out as:
GET /api/auth/reset-password??token=... -> 422
The server read an empty parameter name and rejected the request, which is
correct. Now the call site appends buildQuery's output instead.
The second is why it took this long to find. The page had one failure
path, so every outcome became "this link is no longer valid": a malformed
request, a 500, a dropped connection, and a genuinely expired link were
all reported the same way. That is a confident false statement to make to
someone holding a good link, and it is exactly what happened.
There is now a third state, shown when the service could not be asked. It
says the check could not be completed and that this is not the same as the
link having expired, because it is not. Only the server's own "not
available" answer claims the link is dead, which is what keeps the
non-enumeration property intact: the page still cannot be used to learn
whether a link ever existed.
Two tests. One asserts no request site adds a '?' to buildQuery's output,
and that the named method uses the right shape; reverting the fix fails it.
The other asserts the unreachable state exists and is distinct.
The token from the earlier attempt is still valid until 06:57, so the same
link can be used once this is deployed.
An administrator could not reset a password for bdmurph73@gmail.com, and
there was no way for a signed-in person to change their own. Both are now
in place.
Migration 0002 gives single-use tokens a purpose. 0001 reused the
control plane's unused bootstrap_tokens for email verification, and the
table has no notion of what a token is for, which was invisible while
there was only one kind. With a second kind it stops being invisible: a
reset link clicked by anyone holding it would confirm an address, and a
verification link would set a password. The column defaults to the
existing purpose, so no row is rewritten and verification is unchanged.
Every token lookup now filters on purpose, and the adapter reads the
purpose from the row rather than hardcoding it.
An administrator may start a reset but never finish one. The link goes to
the address on the account, only its holder can complete it, and the
endpoint returns a bool rather than a password. An administrator who can
set someone's password can also sign in as them indefinitely. A reset
revokes every session, because a reset is what someone reaches for when
they think their password is compromised, and revoking only the session
that completed it would leave a stolen cookie working.
Reset links live one hour against the two days a verification link gets.
A verification link confirms an address the person already controls; a
reset link is a live credential for as long as it is valid.
Three bugs found while building it, all mine:
- request_password_reset did not call _authorize_target, so an organization
administrator could start a reset on an account in another tenant. Caught
by a test written for the scoping rule that revoke_sessions already
followed.
- The in-memory repository never checked token expiry, only PostgreSQL did.
So an expired link was honoured in every test and refused in production -
the wrong direction for a divergence between two stores of the same rule.
- The PostgreSQL adapter's get_live_verification_token took a purpose but
not the now its in-memory counterpart needed, so the first call against a
real database raised TypeError and returned a 500. The conformance test
only checked that a method existed. It now compares signatures, which
immediately caught that the protocol itself was missing the parameter.
The reported failure was a deadlock rather than a missing feature. The
account was invited: it could not sign in because the address was
unverified, and I refused to reset anything not yet active. It is now
allowed, and completing the reset also confirms the address, since holding
the link proves control of it. Without that the flow would not converge -
reset the password, sign in, refused again, using a verification link
that was never sent. Suspended and deactivated accounts are still refused,
because a reset restores sign-in and would otherwise undo a suspension
without going through the status controls.
Verified live: the account that could not be reset is now active, verified
and able to sign in, with password.reset_requested, password.reset_completed
and email.verified all in the audit trail. A reset token is refused by the
verification endpoint, an invented token gives the same answer as a used
one, and a token in a reset URL is redacted from the access log.
327 tests pass, 337 with the integration suite.
The "Edit name / slug" button on the organizations page did nothing.
It calls openOrganizationEditor, which filled the dialog in and returned
without displaying it. Every sibling helper in the file - the account
status dialog, the revoke dialog, the organization status dialog - opens
its modal, so the name was carrying a promise the body did not keep.
It went unnoticed because the "New organization" button works, and that one
is a static [data-open] control whose handler calls openOrganizationEditor
and then opens the modal itself. The row button is built in JavaScript, so
the handler bound to [data-open] at startup never saw it, and nothing else
displayed the dialog. Both paths share the helper, so one worked and one
did not, which is what made it look like a dead button rather than a
missing feature.
The editor now opens the modal as well as filling it. The generic handler
still calls open() afterwards, which is a no-op because the modal guards on
being open already.
A new test asserts that every helper named open* in the file opens a
modal, so the next one cannot break the same way. Verified it fails with
the fix reverted and passes with it.
302 tests pass, 311 with the integration suite.
Two separate problems, one of them mine and serious.
The confirmation link did nothing. `request` in api.js refuses a
state-changing call with no CSRF token *in the browser*, before fetch is
reached, so no request was sent and nothing appeared in the service log.
The confirmation page is the only page that arrives without having made a
safe request first, because it is opened straight from a link in an email,
so it was the only one with no token. Added `ensureCsrfToken`, which
fetches one if there is none, and called it before the POST.
The alternative was exempting /api/auth/verify-email from CSRF the way
login and first-run setup are. Those are exempt because nothing about them
relies on a session; this one is state-changing and a session can exist,
so the exemption list stays short.
Verified against a live token: page loads, no cookie, priming GET, then
POST returns 200 and the account becomes active. Replaying the same link
then returns 400, so single-use still holds.
The second problem is worse. This commit's earlier state truncated
users, organizations and audit_events after every test, and I ran it
against the deployment database. It destroyed the two accounts and the
organization that were in it. My own "leave the database clean" change
from two commits ago caused it: TRUNCATE removes the suite's rows and
every other row equally, and the check that had been passing only
asserted the suite's rows were gone, which is also true of a full
database. It passed because I had just emptied that database by hand, so
it never tested the case that mattered.
Tried fixing it by deleting selectively instead. That cannot work either:
audit_events has no ON DELETE CASCADE, and app.prevent_audit_mutation
raises on delete, so the rows cannot be removed one at a time at all.
So the safety is not in cleaning up carefully, it is in refusing to
start. Two guards: the DSN must be accompanied by
UMBRATRANS_TEST_DATABASE_IS_DISPOSABLE=1, because a database name is not
evidence and an empty database is indistinguishable from an unconfigured
deployment; and the suite then refuses to run if the target holds any
rows, naming them. Verified both ways, with a planted row that survives.
The deployment database is cleared and first-run setup is open again.
The gap this closes: the operator command that creates the first
administrator refuses to run in production, and production refuses to
start without SMTP credentials that only the operator has. So a real
deployment could not be brought up at all. Everything else already worked.
Setup is offered only when both of these hold, checked against the store
on every request rather than cached at boot:
* no platform administrator exists, and
* the token generated at that boot is presented.
The token is what keeps this from being first-requester-wins: anyone who
reached the URL before the operator would otherwise become the platform
administrator. It is written to the journal, so control of the service is
still what authorises the first administrator, and it is regenerated on
restart.
It is spent when it authorises rather than when the account is created, so
a password the policy rejects cannot leave a shown token usable. Five
failed attempts burn it. Comparison is constant-time. The status endpoint
never returns it, and there is no endpoint that would.
One deliberate exception to the log filter: the announcement uses no
key=value shape, so redact_text leaves it alone, and a test pins both
halves — the setup code still gets through, and `setup_token=` still does
not. The filter is not weakened, disabled, or special-cased.
Also:
- a `staging` environment, so a deployment can be brought up before SMTP
exists. It holds every production rule except that one, and the waiver
that permits it is refused in production outright, reported by
/api/health as mail_deliverable, and shown as a standing banner in the
control panel, which then refuses to create accounts. A staging
deployment cannot become a convenient way to run something production
would not allow.
- `serve` accepts `--allow-lan-bind` for a named private address, because
the reverse proxy runs on another host. It refuses a wildcard and a
public address, so the flag cannot open the port to the internet. AF_UNIX
was added to the unit's address-family set, without which the PostgreSQL
socket connection cannot be made.
- `count_users_with_role` on the repository, counting active assignments
rather than profile rows, because a suspended administrator is not one.
- setup.html and the sign-in link that appears only while setup is
outstanding, plus the deployment runbook and a rootless unit.
A test of mine was asserting over every credential row in the shared
scratch database rather than the one it created, so it passed only while
no account had replaced its one-time password. Scoped it, and added the
other half of the property.
279 pass without a database, 288 against one.
The link the service builds pointed at /verify-email?token=..., but the
frontend is mounted as static files and that mount resolves
verify-email.html and not a bare /verify-email, so the link 404'd.
Following the confirmation email did nothing at all, which is the one
step of the flow that cannot be worked around.
The existing test followed /api/auth/verify-email, which is the API route
and not the link that goes in the message. Two different URLs, so the page
a recipient clicks was never exercised. The new test builds the app with
a static root the way a deployment does and requests the path taken out
of the delivered body, so the two can no longer drift apart.
web/verify-email.html and web/assets/verify.js are the landing page. It
consumes the token from the query or the fragment on arrival, accepts a
pasted link or a bare token, and strips the token from the address bar
once used so it is not left in history. It reuses the sign-in card's
styling and the shared API client, and carries no inline script or style,
to stay inside the content security policy.
The duplicate verification view that had been added to the sign-in page
is removed in favour of this one, and its dead bindings with it. The
sign-in page keeps the panel that appears when the API answers
email_not_verified, since that is only ever reached once the password has
already been proven correct.
Icon: the supplied artwork is now UmbraTrans_Icon2_899x920.jpg, gold and
cyan on white, with the wordmark corrected to "UmbraTrans". It is copied
verbatim to web/assets/umbratrans-icon.jpg as the source, and
web/assets/umbratrans-emblem.png is derived from it and is what the pages
load: the book cropped to its ink bounds, the white page made transparent
by flood-filling inward from the corners so the white lettering inside
the book survives, letterboxed square rather than side-trimmed so no part
of the emblem is cut, and emitted at 256px.
A square crop of a 827x695 emblem has to choose between trimming the
sides and letterboxing, and trimming was cutting the book's frame. The
glow also moved from box-shadow to filter, because a box-shadow follows
the border box rather than the artwork's alpha and would have drawn a
glowing rectangle behind a book-shaped emblem.
The UI was still written against username login, so the sign-in form
could not have worked against the API: it posted {username, password} and
LoginRequest requires {email, password}. Fixed, along with the rest of
the email-verification flow that had no UI at all:
- the sign-in form collects an email address, and its copy says so;
- a 403 email_not_verified, which is only ever reached once the password
is already proven correct, reveals a panel offering a new link. The
resend wording is the server's and is identical for real and unknown
addresses;
- a confirmation view accepts a pasted link or token, and one is opened
automatically when the URL carries a token, which is then stripped from
the address bar so it is not left in history;
- the password-policy hint read the sign-in field to tell the user their
password must avoid their "account name", but the rule checks the
account's username. It now uses the signed-in account's handle, and
says nothing rather than the wrong thing when there is no handle;
- the provision form called email optional while the API requires it,
and let a member be created with no organization, which the store
rejects. Both are caught client-side now with wording that names the
field to fix;
- `serve` accepts --repository postgres, so the app can be exercised
against the authoritative store.
Verified against a live server on PostgreSQL: sign-in with the email
address, forced first-password change, provisioning an invited member,
401 for a wrong password and 403 email_not_verified for the right one,
and byte-identical resend answers for a real and an unknown address.
The pages under web/ were a static mockup with hardcoded names and counts. They
now call the real backend, and the areas with no backend are explicit empty
states rather than invented numbers.
Real: sign in, the mandatory first-login password change, sign out, the account
table with filters and lifecycle actions, organization create/rename/re-slug/
status, the audit log, and dashboard counts scoped to the caller. The project
workspace keeps its Studio identity and its real session wiring, but its
project, maintenance, and statistics areas now say which backend work is
pending instead of showing sample figures.
Removed the mock release-readiness pipeline and the fake production metrics
rather than restyling them.
The client depends on properties the server already enforces, and states them
where a future editor will look:
- No secret is sent by a client. Account provisioning has no password field;
the service generates the one-time password and returns it once.
- That one-time password lives in exactly one variable, is cleared when the
reveal panel closes and on pagehide, and is never written to storage, a
cookie, the URL, the page title, or the console.
- Every unsafe request carries X-CSRF-Token read fresh from the readable
cookie, and GET /api/health on page load bootstraps it because the
middleware issues none on an unsafe method and login is exempt.
- Nothing is trusted from the browser. Controls a role cannot use are hidden,
and the service re-checks each one.
Rendered markup is built by an el() helper that routes all server-supplied text
through textContent, with no innerHTML anywhere. The response CSP is
script-src 'self' and style-src 'self', so an inline script or style attribute
would be refused by the browser; there is none.
Verification, and its limits. The banned-pattern grep for inline script, inline
handlers, style attributes, innerHTML, storage, and console output comes back
empty. Replaying the exact request sequence the client performs against a real
uvicorn server confirmed all 86 contract assertions: every field the
JavaScript reads is present, the CSRF cookie is issued by the health probe, the
one-time password is absent from every later response, and the error codes the
UI branches on are the ones the API returns.
The client was NOT executed in a browser. No desktop browser is attached to
this session and no JavaScript runtime is installed on this host, so the
one-time-password reveal, the modal focus trap, and the role gating are
verified by reading and by the contract replay, not by driving a real browser.
That gap needs closing before this is trusted.
web/README.md replaces the mock description, including the run command and the
fact that this is not a production deployment: create_app still refuses to
start in production because the PostgreSQL adapter does not exist.
The prototype was served by a bare `python3 -m http.server` in a foreground
shell. It has no restart policy, so it died silently and the public URL
returned a 502 from Caddy with valid DNS and a valid certificate already in
place. This unit makes the failure mode visible and recoverable.
umbratrans-web-prototype.service:
- Restart=on-failure with a 5s delay and a 120s/5-attempt start limit, so a
crash loop stops retrying but a clean stop always starts again.
- Binds 0.0.0.0:8442 serving web/; Caddy keeps terminating TLS upstream.
- Sandboxed: runs unprivileged with an empty capability bounding set, no new
privileges, read-only home and system, private tmp and devices, system call
filtering, and no access to kernel or control-group interfaces.
- Journal output to the user journal for inspection.
PrivateUsers=yes is documented as deliberately omitted. It hides /etc/group
from the manager, so systemd cannot resolve supplementary groups and the unit
fails with status=216/GROUP. A probe unit confirmed the remaining sandbox
directives are all valid in user scope.
Verified: all three routes and the icon return 200 locally and the public
HTTPS URL returns 200. SIGKILL of the main PID recovered automatically within
the restart delay. `systemd-analyze verify` is clean.
Two follow-ups requiring root are recorded in web/README.md: the rootless
install must drop User= because a --user manager cannot setuid, and
`loginctl enable-linger umbra` is needed for the service to survive logout and
start at boot.