16 Commits

Author SHA1 Message Date
umbra audio
fbbe23d7f9 Add output generation, cover image management, and back-translation tool
- Add cover image CRUD endpoints: GET/POST/DELETE /api/projects/{id}/cover
- Add output generation endpoints: POST /api/projects/{id}/output/{format}
- Add cover image and output queue service methods
- Add CoverImageResponse and CoverImageUploadResponse schemas
- Add umbra-api.js request handling for FormData (cover uploads)
- Add output generation buttons (EPUB/PDF/Audio) to Format UI in umbra-app.js
- Add backtranslate.py tool for verifying translation quality via back-translation
- Add cover image support to render.py EPUB generation
- Copy cover image to deal-hunter-zh source directory
- Spanish translation pipeline started (running in background)
2026-10-25 15:27:02 -07:00
umbra audio
be04e19d04 Add UmbraAudio controller integration endpoints
- Add migration 0010: utterance_translations table with UNIQUE(utterance_id, language_code)
- Add schemas: OrganizationCreateRequest/Response, ProgramCreateRequest/Response,
  UtteranceTranslationItem/Request/Response/ListResponse, SpeakerSyncRequest/Response
- Add service methods: create_organization, create_program, sync_speakers,
  upsert_utterance_translations, get_utterance_translations
- Add routes_controller.py: POST /api/orgs, POST /api/programs,
  POST /api/speakers/sync, POST /api/projects/{id}/translations,
  GET /api/projects/{id}/translations/{language_code}
- Mount controller_router at /api in app.py
- Create bdumbra account (member, BDMurphy org) for integration testing
2026-10-25 13:11:42 -07:00
umbra audio
3e0568b9d1 Generate backend endpoints for language-project workflow
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
2026-10-25 11:04:53 -07:00
umbra audio
e35d7a144f Add Apache-2.0 LICENSE, NOTICE, and SPDX headers 2026-10-18 19:01:05 -07:00
umbra audio
79b3484083 Reach the tenant tables by name, not by a filter somebody remembers
Every project statement now says `projects` unqualified and takes its
organization through search_path. A shared table filtered by
`organization_id = %s` is a filter somebody has to remember, and there is always
a write path that would not have it; an unqualified name resolves into the
caller's own schema or it does not resolve at all. The strongest possible
mistake - forgetting to scope a query - now raises instead of returning a wider
result set.

Project.organization_id survives in the model and is filled in from the schema
the answer came from. Callers still see which tenant a row belongs to; it is
derived from where the row is rather than from what the row says about itself.

Two consequences that are worth more than the mechanical change:

  - assign_user_to_project now takes the organization as an argument. It used to
    read it off the project row and copy it into user_roles, and there is no
    column to read now. user_roles is platform-level, so it cannot be written to
    by looking inside a tenant schema - so the caller, which has already resolved
    the project, has to say it. The protocol declares it, and the in-memory store
    accepts and ignores it rather than being the one implementation with a
    different signature.

  - A platform administrator listing projects without naming an organization now
    gets the answer assembled across tenant schemas, because there is no shared
    table to ask. That is one round trip per organization and it is paid only by
    that one caller; every per-tenant path, which is what an ordinary request
    does, is still a single statement. The merge reproduces the ordering and the
    total a shared table would have given, so paging across the boundary is the
    same as paging inside one.
2026-10-03 13:32:46 -07:00
umbra audio
d13664a607 A compute substrate: node inventory, a leased queue, and an agent
Implements §6.16-6.20 and §7 of the specification, and proves it end to end on
this box: a controller and an agent, the agent with no database credential at
all, ten checks against the properties whose failure modes are silent.

Migration 0003 adds compute_nodes, compute_devices, node_credentials,
model_registry, node_models, node_heartbeats and job_events, and creates jobs
- which §6.8 had specified and 0001 had never migrated. Every column added to
jobs is nullable or defaulted, every policy is written against the same app.*
helpers the existing eleven tables use, and re-applying is safe: CREATE POLICY
has no IF NOT EXISTS, which is how 0001 learned the same lesson for ADD
CONSTRAINT.

Four properties, and each exists because the alternative fails silently:

- **I25** claiming is FOR UPDATE SKIP LOCKED. Two agents claiming at once get
  different jobs. The proof of purpose counts the 'claimed' events rather than
  counting successes, because two agents both succeeding is not evidence.
- **I26** a lease that expires returns the job to the queue, and the proof of
  purpose SIGKILLs the holder rather than stopping it, because a clean stop
  would release the lease and a clean stop is not the failure being tested.
- **I24** a node is online because it heartbeated. Creation is not readiness.
- **I28** a node credential reaches queue operations and nothing else. The
  organization comes from the credential, so a forged tenant identifier has
  nothing to attach to.

And the one that is hardest to get right in the cheap direction: **a device
that is slow is eligible, and one that is too small is not.** A model that does
not fit in VRAM runs on system RAM, slowly, and a job nothing can run is
'blocked' with the reason rather than left looking like it is progressing.

The proof of purpose found six defects, listed in §7.8. The two worth naming
here:

- Mounting one router at two prefixes published the administrator's routes
  behind node-credential authentication, so a node could create nodes and issue
  itself more authority. Two routers now, with a test that pins the surfaces
  disjoint and the node surface exactly.
- **The correction I was given about unified memory was in the specification and
  not in the code.** Eligibility was VRAM-only, so every CPU and every
  shared-memory machine was ineligible, which would have reduced the fleet to
  one machine. Writing the implementation is what kept the error alive.

Also: the CSRF middleware blocked the whole node API, because a
bearer-authenticated agent has no ambient cookie for a cross-site request to
ride on. Exempted by prefix, with a test asserting that no cookie-authenticated
route joined it.

419 tests pass with the integration suite against a real database. The
deployment database is untouched and has no compute tables.
2026-09-29 20:09:36 -07:00
umbra audio
ed72f61975 Separate organizations, and give members their organization's projects
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.
2026-09-28 11:34:37 -07:00
umbra audio
e034061ec1 Add password reset and self-service account settings
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.
2026-09-27 23:46:59 -07:00
umbra audio
17f8e81fb7 Verify the mail transport instead of trusting its configuration
Gmail rejects an account password for SMTP and wants an app password
instead, which it says in a reply nobody reads. Configured with
smtp.gmail.com:587, a port, and a password, the deployment looked healthy:
`_mail_is_deliverable` asked whether the transport was smtp and a host was
set, both true, so /api/health reported mail_deliverable=true and the
control panel allowed account creation.

Every account created that way would have been stranded in `invited` with a
confirmation link that was never delivered and cannot be re-sent, and the
administrator would have seen a success response each time. That is a data
problem created by a configuration problem, invisible until somebody tried
to sign in.

So the transports report whether they can actually deliver:

- `Mailer.verify()` on all three, sharing the connection path with `send` so
  a probe cannot succeed by a route real delivery would not take.
- SmtpMailer authenticates and disconnects, and logs the server's own
  rejection code, because "wrong password" and "server unreachable" are
  different problems and a bare False throws that away. The reply contains
  no credential.
- health reports the verified state, not the configured one.

And enrollment refuses when mail cannot be delivered, in the service
rather than the browser. The control panel already disabled its own
buttons, but a client-side check is a courtesy, not a control: anything
reaching the process would still have created the account. Exempt is the
operator path, which attests the address instead of mailing it, because
guarding that would deadlock first-run setup behind SMTP configuration,
and development, where the logging transport is the point and the flow
would otherwise be untestable.

Verified against smtp.gmail.com on both 587 (STARTTLS) and 465 (implicit
TLS): reachable, TLS established, AUTH offered, and 535 5.7.8 "Username and
Password not accepted" on both. The network and the server are right; the
credential is not.

286 tests pass without a database, 295 with one.
2026-09-27 06:49:23 -07:00
umbra audio
ef6bf1e6a3 Add first-run setup so a deployment can be finished in the browser
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.
2026-09-27 06:13:58 -07:00
umbra audio
ef9785a5da Give the confirmation email a page that resolves
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.
2026-09-26 22:14:28 -07:00
umbra audio
64af39eb68 Run the PostgreSQL integration suite against the real schema
The integration suite had never been executed. Running it against a
PostgreSQL 16 instance carrying an exact copy of the control-plane
schema found seven real defects, all of which were invisible without a
database.

Migration:

- `ADD CONSTRAINT ... UNIQUE USING INDEX` failed when the index was
  already attached, for both `organizations_slug_key` and
  `users_username_key`. Because the file runs in one transaction, that
  error rolled back every other change, leaving a half-applied schema
  rather than an obviously untouched one. Both are now guarded, so the
  file applies cleanly to the real schema and is idempotent on re-apply.

The adapter:

- `get_user_by_email` was never implemented. The protocol stub returned
  `None`, so email login found no account against a real database while
  working perfectly against the in-memory one.
- The four verification-token methods were never implemented either, so
  email verification links never worked against PostgreSQL.
- The RLS identity GUC was wrong. `app.current_user_id()` reads
  `app.user_id`, not `app.current_user_id`; setting the similarly named
  GUC left every policy helper resolving to NULL, so every policy denied
  and every query returned zero rows. Added `verify_rls_contract()`,
  called at startup, which proves the two agree. It has teeth: pointed at
  a wrong GUC it refuses.
- `SET` takes no bind parameters, so the statement timeout was a syntax
  error on every transaction. The identity GUC now uses `set_config`,
  which is parameterisable and keeps the value bound.
- `append_audit_event` returned only `id` and then mapped the whole row,
  and passed a text `target_id` where a `UUID` was required.

The domain:

- A member with no organization is storable in the development JSON
  store and rejected by `user_roles_check`, so the same call succeeded in
  development and failed in production. Enforced in the service now, and
  43 tests were relying on the JSON store's leniency.
- `create_account` accepted `require_email_verification` and silently
  dropped it on the operator path, returning an unverified account to a
  caller that had asked for a verified one. Now refused, with the reason.
- All 26 protocol stubs returned `None` instead of raising, so a missing
  method was indistinguishable from an empty database. They now raise, and
  a conformance test checks each repository implements every declared
  method rather than merely inheriting it. The old conformance test used
  `hasattr`, which inheritance always satisfies; it passed while five
  methods were missing.
- The bootstrap CLI refused an unsatisfiable role only after the operator
  had typed a one-time password.

The migration README now records which review questions are answered and
which remain, including that RLS is enabled but not forced, so the
adapter's own scoping is currently the only tenant control and the
isolation tests below it are still outstanding.

255 pass without a database, 263 against one.
2026-09-26 21:55:00 -07:00
umbra audio
0461dff9d2 Use the email address as the login identifier and require confirmation
Sign-in now takes the email address rather than a username. This needed no
migration: users.email is already citext NOT NULL UNIQUE in the control plane,
so it is a case-insensitively unique login key on its own. username survives as
an optional display handle for audit lines and is never a login path, which
removes the redundancy of asking an administrator for two identifiers.

An account created by an administrator now starts in the control plane's
invited state and cannot sign in until a single-use link is consumed. The token
maps onto bootstrap_tokens, which was already shaped for exactly this and had
been unused, so that needed no migration either. Modelling invited as a real
state also retires the adapter's earlier fail-closed refusal of that value,
which existed because invited was previously unrepresentable.

Three decisions worth stating plainly.

The "email not verified" message is sent only after the password has been
verified. Reporting it earlier would turn the login form into an oracle for
which addresses have accounts, which is the one thing the uniform
authentication error exists to prevent. A test asserts that a wrong password and
an unknown account are byte-identical, and it caught a real bug: the check was
first written before the password comparison, and the generic "not active"
branch swallowed invited accounts. Both are now ordered deliberately.

The operator bootstrap path does not require verification. The operator is
attesting to the address themselves, which is the standing an administrator
already has, and requiring a delivered email there would deadlock first-time
setup on SMTP configuration that does not exist yet.

The logging mail transport is refused in production. A deployment that logged
links instead of sending them would appear to work while every new account sat
permanently unverified.

The development transport writes the link with the token redacted, because a log
is where live credentials end up. That means a human cannot complete the flow by
reading the log; the test harness captures the message in memory instead. This
is a deliberate limitation rather than an oversight, and it is documented.

Also: production now requires a mail transport and an absolute verification base
URL alongside the existing DSN gate, because an account that cannot be verified
cannot be used at all.

269 tests pass, 3 skipped. Verified end to end against a real uvicorn server:
bootstrap login, organization creation, an invited account with its one-time
password, and a handle rejected as a login path.
2026-09-26 21:21:01 -07:00
umbra audio
31fd8720ec Add the PostgreSQL adapter for the authoritative store
Implements AccountRepository against the control-plane schema plus the
phase-1 identity migration, and wires it in so production can select it.

The adapter is not a thin SQL wrapper, because the control plane does not
store the two things the application needs on the users row.

Role and organization come from user_roles. There is no role column and no
organization column on users; a role is assigned 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 therefore a join, and
a user holding more than one active assignment is refused rather than resolved
to an arbitrary row. The organization on that assignment becomes the scope of
every administrative decision, so guessing would hand out the wrong tenant's
authority. Reconciling this with project-scoped membership stays an open
review question, recorded as such.

Email became a required field on account creation. users.email is NOT NULL
UNIQUE, so accepting a missing address would let account creation succeed
against the development repositories and then fail on the authoritative store.
It is now required in the service, the API schema, and the bootstrap command,
so it fails identically everywhere.

Audit identifiers now belong to the store. audit_events.id is a bigint
identity, so the adapter reports that value; the application correlation UUID
is preserved in the detail and never displaces the row key. AuditEvent.id is a
string for that reason, which removes a type the two stores disagreed about.

Three further fail-closed behaviours: a users.status of invited or locked is
refused rather than coerced onto active; a row with mfa_required is refused,
because this phase cannot satisfy a second factor and authenticating it anyway
would grant access to someone who was supposed to present one; and a missing
organization blocks access rather than granting it. save_user deliberately does
not write username or email, since both are identity keys with no reviewed
change path and no audit event behind them.

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 is inert until the reviewed RLS
policies exist; the explicit WHERE clauses enforce scope today. A statement
timeout bounds how long a pooled connection can be held.

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. Verified by running both, including that an unreachable host
refuses to start rather than degrading.

The migration gains an index on audit_events (target_type, target_id), which
the administrator audit filter needs and nothing covered, and the review
questions now include the three things that must be checked before
provisioning: the roles rows and their scopes, any row with mfa_required set,
and any user holding more than one active assignment.

Verification, and its limit. 236 tests pass with 3 skipped. The adapter's row
mapping, role resolution, conflict mapping, and configuration are unit tested
and run everywhere. Its SQL, transactions, and pooled reuse are NOT executed:
this environment has no database credentials, because sudo to the postgres user
needs a password, there is no umbra database role, and there is no .pgpass. The
integration suite is written and selected by UMBRATRANS_TEST_DSN, and its
fixture refuses any DSN whose database name contains umbracontrol. A green run
here says nothing about whether the SQL is correct.
2026-09-26 20:28:07 -07:00
umbra audio
0b9c4d4220 Add organization tenancy and same-origin frontend mounting
The administrator interface needs organizations to manage, and the slice had
none: users.organization_id was a bare UUID pointing at nothing, so there was
no tenant to create, list, or validate an account against.

Tenancy:
- Organization record with name, slug, and status active|suspended|archived,
  matching the control-plane vocabulary rather than reusing the account one.
- Reads are open to any administrator; every write is platform-admin only, so
  the tenant boundary stays under a single authority. An organization admin is
  pinned to the organization on their own principal.
- A derived slug is normalized from the display name, but a slug the operator
  typed must already be canonical, so nothing is silently rewritten behind
  their back. archived is terminal.
- GET /api/uadmin/summary for the dashboard, recomputed per request from the
  same scoped queries the list endpoints use, so a summary cannot disclose a
  number the caller could not reach by listing.

Two integrity gaps closed:

1. Account creation accepted any UUID as an organization. It now requires the
   organization to exist and to be active.
2. Suspending an organization revoked member sessions but did not stop new
   logins, because each member's own account status stays active. Both
   authenticate and resolve_session now refuse a member of a non-active
   organization, and a missing organization record blocks access rather than
   granting it. A test asserts the member is still active yet locked out.

Migration, still unapplied: section 4 creates organizations with CREATE TABLE
IF NOT EXISTS so a control-plane table is skipped, never redefined, and the
rollback drops it only when this migration actually created it. The membership
divergence is recorded as the top open review question rather than decided:
user_roles is the control plane's membership source, this phase uses a column,
and the migration adds no membership column so the choice stays open.

Serving: UMBRATRANS_STATIC_ROOT mounts the browser client at / after the API
routes, so the session cookie and CSRF header work without CORS. HTML responses
get their own CSP allowing only same-origin script and style, which is why the
frontend carries no inline script or style.

Verification: 204 tests pass (21 new organization tests plus the existing
suite), ruff check and format clean. Also exercised against a real uvicorn
server rather than only the test client: bootstrap, forced password change,
organization creation, one-time password issue, member lockout on organization
suspension, and rejection of an account assigned to a suspended tenant.
2026-09-26 19:58:17 -07:00
umbra audio
b474995414 Add phase-1 account management backend slice
Implements the account and user-management system behind the new web UI
against development repositories only. Nothing here connects to, reads, or
modifies the production umbracontrol database, and no account is created in
any approved environment.

Account service and API:
- Local temporary-password and OIDC enrollment, separated by type.
- Argon2id credentials, must-change enforcement, server-side sessions with
  hashed tokens, idle and absolute deadlines, rotation and revocation.
- Administrator account management: list, get, create, suspend, reactivate,
  deactivate, revoke sessions, org-scoped audit view.
- Hidden-prompt operator command for the first administrator, refusing
  production mode and refusing to run without a TTY.

Temporary passwords are never accepted from a client. The request schema has
no password field and refuses unknown fields; the administrator path generates
the one-time password in process, hashes it immediately, and returns it once in
the creation response. The account can reach no route, including
/api/auth/me, until it replaces that password, after which the one-time value
no longer authenticates.

Security posture: fail-closed configuration, Secure cookies in production,
double-submit CSRF, constant-time token comparison, generic authentication
errors, audit detail allow-listing, and logger-tree secret redaction.

Reviewed but unapplied: migrations/0001_phase1_identity.sql adds
users.username, a separate user_credentials table, and additive session
columns, with a rollback and open review questions. The PostgreSQL adapter is
deliberately absent, so the application refuses to start in production rather
than run against an unreviewed store.

Verification: 169 tests pass (148 new, 21 pre-existing); ruff check, ruff
format, compileall, and make web-check are clean.
2026-09-26 08:29:23 -07:00