Files
UmbraTrans/umbratrans_web
B.D. Murphy 3f05489e25 Parameterise the translation pipeline by target language
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.
2026-11-07 05:53:17 -08:00
..

UmbraTrans web account and organization management — phase-1 slice

Backend for docs/umbratrans-backend-design.md: accounts and organizations, both enrollment modes, Argon2id credentials, server-side sessions, administrator lifecycle actions, sanitized audit events, and a FastAPI application factory that can also serve the browser application from the same origin.

The PostgreSQL adapter is written but has never been run. The reviewed migration in migrations/ has not been applied, and the integration suite has not been executed, because this workspace has no database credentials. The adapter's pure logic is unit tested; its SQL is not. Read Verification status before trusting it with real data.

The PostgreSQL adapter

Three mappings in postgres_repository.py are load-bearing and are the reason it is not a thin SQL wrapper.

Role and organization come from user_roles. The control plane stores no role column and no organization column on users; it assigns a role through user_roles with a scope, and app.validate_user_role_scope() enforces that the role's scope matches the assignment. Resolving one role per user is a join. When a user holds more than one active assignment the adapter refuses rather than picking a row, because the organization on the assignment becomes the scope of every administrative decision, so guessing would hand out the wrong tenant's authority.

A missing email is impossible. users.email is NOT NULL UNIQUE, so email is a required field in the service and in the API schema. Enforcing it in the domain means a missing address fails identically against the development repositories and the authoritative store.

Audit identifiers belong to the store. audit_events.id is a bigint identity, so the adapter reports that value. The application's correlation UUID is preserved in the detail under application_event_id and never displaces the row key.

Three further fail-closed behaviours are worth knowing:

  • A row whose users.status is invited or locked is refused, not coerced onto active.
  • A row with users.mfa_required = true is refused, because this phase cannot satisfy a second factor. Until MFA exists, every row must be false.
  • A missing organization record blocks access rather than granting it.

Every operation runs in one transaction on one pooled connection and publishes the caller with SET LOCAL app.current_user_id, so a returned connection cannot carry one request's identity into the next. That setting is inert until the reviewed RLS policies are applied; today the explicit WHERE clauses are what enforce scope.

Configuration

Variable Meaning
UMBRATRANS_REPOSITORY memory, json, or postgres
UMBRATRANS_DATABASE_DSN Connection string. Required for postgres, and excluded from repr so it cannot leak into a settings dump.
UMBRATRANS_DATABASE_POOL_MIN / _MAX Pool bounds, default 1 and 10
UMBRATRANS_REPOSITORY_PATH Local JSON store path
UMBRATRANS_STATIC_ROOT Serve the browser client from this directory
UMBRATRANS_ALLOWED_HOSTS Comma-separated host allow-list
UMBRATRANS_COOKIE_SECURE Required true in production
UMBRATRANS_REQUIRE_CSRF Required true in production

Production fails closed at two independent gates: the settings layer refuses without a DSN, and the repository boundary refuses to build a PostgreSQL store without one. A production process cannot start against a development store while appearing to hold real data.

Verification status

Area Status
Row mapping, role resolution, conflict mapping, configuration Unit tested, runs everywhere
SQL statements, transactions, RLS setting, pooled reuse Not executed. No database credentials in the authoring environment.
tests/test_postgres_repository.py integration cases Skipped. Selected by UMBRATRANS_TEST_DSN.

Run the integration layer against a scratch database, never umbracontrol:

UMBRATRANS_TEST_DSN='postgresql://app@localhost/umbratrans_scratch' \
  /home/umbra/.local/bin/uv run pytest umbratrans_web/tests/test_postgres_repository.py

The fixture refuses a DSN whose database name contains umbracontrol.

Email is the login identifier, and it must be confirmed

Sign-in uses the email address. users.email is already citext NOT NULL UNIQUE in the control plane, so it is a case-insensitively unique login key and no migration was needed. username is now an optional display handle for audit lines and is never a login path.

An account created by an administrator starts in the control plane's invited state and cannot sign in until a single-use link is consumed. The token maps onto the existing bootstrap_tokens table, which was already shaped for this and was unused, so that needed no migration either.

Three details that matter:

  • The "email not verified" message is only sent after the password is proven correct. Reporting it earlier would turn the login form into an oracle for which addresses have accounts. A test asserts that a wrong password and an unknown account are indistinguishable.
  • The operator bootstrap path needs no verification. The operator is attesting to the address themselves, which is the same standing an administrator has. Requiring a delivered email there would deadlock first-time setup on the SMTP configuration.
  • The logging mail transport is refused in production. Otherwise a deployment would appear to send verification mail that never left the machine, and accounts would sit unverified with nobody noticing.

The development transport writes the link to the log with the token redacted, because a log is where live credentials end up. So a human cannot complete the flow by reading the log: use SMTP, or exercise it through the test harness, which captures the message in memory.

Tenancy

organizations is a first-class record with name, slug, and a status of active, suspended, or archived. Account creation validates the target organization and refuses a non-active one.

Phase 1 keeps membership as a single organization per user rather than the user_roles join the control plane uses. That is a deliberate simplification, recorded as an open review question in migrations/README.md; the migration creates the table and adds no membership column, so the decision is not silently settled.

Suspending or archiving an organization revokes its members' live sessions, and both authenticate and resolve_session refuse a member of a non-active organization. Revoking sessions alone is not sufficient: each member's own account status stays active and could otherwise mint a new session.

Same-origin frontend

Set UMBRATRANS_STATIC_ROOT and the application serves the browser client from / after the API routes are registered, so the session cookie and the CSRF header work without CORS. There is deliberately no CORS middleware. A missing or non-directory root is refused rather than silently serving an API-only application.

This slice does not connect to PostgreSQL. The reviewed migration in migrations/ has not been applied, and the production repository adapter is deliberately absent, so the application fails closed instead of starting against an unreviewed store.

Layout

Module Responsibility
models.py Frozen dataclasses and enums. Hash fields are repr=False; AccountView and OrganizationView are the only shapes that may cross the API.
security.py Argon2id hashing, password policy, one-time password generation, session/CSRF tokens, log and audit redaction.
config.py Fail-closed settings. Production requires secure cookies, CSRF, and a non-development repository.
errors.py Domain errors with stable codes. Messages are generic so responses cannot enumerate accounts.
repository.py AccountRepository protocol plus the thread-safe in-memory development repository.
postgres_repository.py The authoritative PostgreSQL adapter: pooled connections, one transaction per operation, SET LOCAL identity, and the control-plane role mapping.
dev_repository.py Optional local JSON store: 0600 file, 0700 directory, atomic writes, refuses production.
service.py AccountService: enrollment, authentication, first-password change, sessions, organizations, administration, audit.
schemas.py Pydantic request/response models. Request models forbid unknown fields.
app.py Application factory, security middleware, CSRF, error mapping, static mounting, dependencies.
routes_auth.py, routes_uadmin.py /api/auth/* and /api/uadmin/*.
cli.py Hidden-prompt operator command and a loopback development server.
migrations/ Proposed, unapplied phase-1 identity and tenancy SQL and its rollback.

Enrollment boundary

Both modes are separated by type rather than by convention, and neither accepts a password from a client:

  • temporary_password, operator path: AccountService.create_account requires an OperatorContext, which only the operator CLI builds, plus a must_change_password = True Argon2id credential. The plaintext exists only inside the hidden prompt call and is never persisted, logged, or audited.
  • temporary_password, administrator path: AccountService.provision_admin_temporary_password generates the one-time password in process, validates it against the local policy, hashes it, and returns it once in the creation response as IssuedTemporaryPassword. The value is excluded from that object's repr and is not retrievable again by any endpoint.
  • oidc requires an authenticated administrator principal and stores no local password. An oidc credential with a hash is rejected by the model.

CreateAccountRequest has no password field and refuses extra fields, so a submitted password is rejected by the schema before a handler runs. Account creation returns CreateAccountResponse, whose temporary_password field is populated only for the generated-password mode.

API

GET    /api/health
POST   /api/auth/login
POST   /api/auth/change-first-password
POST   /api/auth/logout
POST   /api/auth/logout-everywhere
GET    /api/auth/me
GET    /api/uadmin/summary
GET    /api/uadmin/accounts
POST   /api/uadmin/accounts          (either enrollment mode)
GET    /api/uadmin/accounts/{id}
PATCH  /api/uadmin/accounts/{id}/status
POST   /api/uadmin/accounts/{id}/revoke-sessions
GET    /api/uadmin/organizations
POST   /api/uadmin/organizations      (platform admin only)
GET    /api/uadmin/organizations/{id}
PATCH  /api/uadmin/organizations/{id}         (platform admin only)
PATCH  /api/uadmin/organizations/{id}/status  (platform admin only)
GET    /api/uadmin/audit
  • Sessions are server-side. The cookie holds a random token; only its hash is stored. The cookie is HttpOnly, SameSite=Lax, and Secure whenever production settings are used.
  • CSRF uses the double-submit pattern: the readable umbratrans_csrf cookie plus an X-CSRF-Token header compared in constant time. POST /api/auth/login is exempt because it carries credentials an attacker cannot know. A safe request (for example GET /api/health or GET /api/auth/me) issues the cookie and returns the token.
  • No CORS middleware is installed; the API is same-origin behind the approved reverse proxy.
  • Error bodies are {"error": {"code", "message"}}. Validation failures report field names only, because FastAPI's default payload would echo a submitted password.
  • POST /api/auth/login returns the CSRF token; GET /api/auth/me returns it again, so a client can bootstrap without a separate endpoint.

Operator bootstrap

cd /home/umbra/src/UmbraTrans
UMBRA_ENV=development \
  /home/umbra/.local/bin/uv run umbratrans-bootstrap-admin \
  --username Umbraadmin --repository-path .local/data/development/umbratrans-web/dev-accounts.json

The command reads the one-time password twice from a hidden TTY prompt, refuses to run without a terminal, refuses production mode, and writes only to the local development store. Nothing it prints contains the password. It has not been run in this workspace.

python -m umbratrans_web bootstrap-admin is equivalent. python -m umbratrans_web serve starts the development API on 127.0.0.1 and refuses a non-loopback bind or a production environment.

Development

cd /home/umbra/src/UmbraTrans
/home/umbra/.local/bin/uv sync --python /usr/bin/python3
/home/umbra/.local/bin/uv run pytest -q
/home/umbra/.local/bin/uv run ruff check .
/home/umbra/.local/bin/uv run ruff format --check .
/home/umbra/.local/bin/uv run python -m compileall -q umbratrans_web

The suite uses the in-memory repository and a reduced Argon2 cost for speed; tests/test_security.py asserts the production parameter profile separately. Test passwords are generated per run, so no fixture file carries credential material.

Not in this slice

  • The PostgreSQL adapter, the OIDC callback and subject mapping, MFA enforcement, user_roles membership, invitations, and audit export.
  • Connection to umbracontrol, application of migrations/0001_*, and the first administrator in any approved environment.
  • Serving the static prototype: web/ is unchanged and is not mounted.