Routes and backend proxy
Routes and application structure
The web plugin capability DTO and PluginPermissionsPanel use erased imports of PluginCapabilities from src/plugins/capabilities.ts, so the browser does not load the daemon schema. The panel's mutation-tone map is exhaustive over the canonical union: turnContext uses warning and all other enforced mutations, including users, use danger. A detail response with capabilities: { mutates: ['users'] } renders a danger badge in the Capabilities tab. An absent declaration hides capability rows. This read-only presentation of declared authority adds no request or enforcement and is consumed by Settings' PluginDetail.
GET /projects/summary consumes ProjectService.authorizedRows so its rows and order match the authorized project listing without repeating filesystem projection. A plugin registry load that never settles expires after its existing five-second budget; the register still receives its authorized cards without unavailable indicators or uncached managed branches. Existing member visibility and branch caching are unchanged.
accountProjectRefusal(error) validates structured account/project refusals at the browser boundary. It first recognizes the code through accountProjectRefusalSchema.shape.code.safeParse, then strictly parses the full body. Unknown codes and non-API errors return undefined; a malformed recognized refusal throws its validation error. ProjectsView consumes last_active_project to list affected accounts in the removal confirmation. The helper adds no network request and does not change server refusal codes.
UserSections.ProjectsSection (the User drawer's Projects tab) supplies disabled: project.lifecycle === 'deleting' and a localized disabledHint through ManageSelectionItem. Deleting projects remain visible, and existing assignments remain selected when the picker opens so their members can still observe cleanup. Active projects retain normal selection behavior. The save diff excludes projects already marked deleting, including a project whose deletion began after its local selection changed; active replacements are granted before old active memberships are revoked. This adds no request or shared picker option. Server authorization remains authoritative for deletion races after submission.
Project removal in ProjectsView uses one local openRemove(project) entry from the project card's action menu. Each opening clears the previous structured account refusal before selecting its target. A last_active_project refusal keeps the confirmation open and lists the affected accounts; without a refusal no account warning appears. ConfirmDialog owns pending submission and dismissal protection, while ProjectsView retains its in-flight removal guard. The host metadata removal and managed teardown routes remain unchanged.
The memory module resolves category colors through memoryMeta.categorySwatch(color), including grouped headers in MemoryRow and graph colors in brainLayout. Pass the stored category color or undefined for the uncategorized group. The helper trims the value and returns the muted foreground token for an absent or blank color. It runs synchronously when the consumer builds its display data, with no requests or persistence; category icons and labels remain the consumer's responsibility.
memoryMeta.vitalityTone(value) returns only danger below 30, warning from 30 to below 65, and success from 65 upward. MemoryRow maps those tones to background classes, while MemoryVitalityChart maps them to SVG stroke colors; both private maps are keyed by the helper's return type. Use the server-computed vitality score when rendering a memory. The helper is constant-time and adds no retention logic, history fetches, or plugin UI exports.
memoryMeta.memorySnapshotBody(json) reads a stored audit snapshot with the existing snapshot parser and returns its string body, or null when the snapshot or body is absent or malformed. Both eventSummary in the memory audit feed and MemoryDreamingCard use it; the audit feed prefers After and then Before, while the dreaming card shows only Before for deletion, only After for addition, and After once when bodies are identical. Parsing is synchronous and linear in snapshot size, adds no requests or persistence, and never exposes raw row metadata in the card.
MemoryDreamingCard composes the shared Button and ConfirmDialog for removing a settled maintenance record. Its icon-only control has a translated accessible name and title, appears on row hover or keyboard focus, and remains visible with coarse pointers or no hover. Running rows offer Stop instead. The existing memory mutation layer calls DELETE on the owner-scoped run route, clears that run's detail cache and invalidates the memory-dreaming query prefix shared by history and the operation dock. Successful removal returns history to its first page; a failed request uses the shared error toast. MemoryAuditFeed continues to render retained events directly without depending on, or linking to, a deleted run.
The route tree is under web/app/:
/redirects to/dash./dashis the workspace home./guidesis the signed-in product manual;?q=preserves a shareable search./guides/category/<id>lists a canonical category and/guides/<slug>#<section-id>opens an article or stable section. Thin route entry points delegate toweb/modules/guides; these are ordinary pages, not intercepted overlays. Home search updates the same document's?q=throughannounceLocation, preserving a responsive local input and the router history state; back/forward or a pasted query rehydrates it. UI locale selects the variant without changing article or section identity. The existing login and licence gates still apply, and administrator audience labels are guidance rather than article ACLs./chatis the full-page advisor./memoryis the account memory register. Importance is shown and edited on a 1 to 10 scale (default 5,web/modules/memory/memoryMeta.ts) through the memory module'sRankSliderinweb/modules/memory/MemoryFields.tsx, shared by the create and edit surfaces, and the vitality chart reports the eviction date, however far ahead, or nothing while retention is paused: no level is "never deleted".MemoryViewowns filters, paging, selection and the detail rail; its private module components areMemoryRow(including grouped category headers and vitality cells),MemoryModals(create and merge), andCategoryModal(category create/edit, used by both the view andCategoryManager). The moves preserve row keyboard traversal and selectors, modal fields, and create-then-category write order.MemoryAuditFeedshares the existinguseNowheartbeat with list rows: relative timestamps advance without new HTTP data, sleep while the tab is hidden and catch up on visibility.brainLayoutusesmemoryMeta.categorySwatchfor category and uncategorized colors, andMemoryBrainMapreads its core color from the graph; uncategorized grouped headers retain the muted Hash icon. No plugin UI exports or persistence contracts are added.
Memory maintenance uses the existing useMemoryMaintenance query. Its durable state is also available to shell consumers.
The server owns durable progress, UI initiation, terminal counts and acknowledgement.
The runs projection includes only the caller's hand-started work and results within a 24-hour window,
bounded to twenty receipts; the latest reindex and recategorize slots remain available to the maintenance view.
The query polls once per second only while either operation runs. After daemon recovery an unfinished
run displays "Interrupted by a restart" with its recorded success and failure counts.
Navigate to /memory?maintenance=1 to open the existing maintenance modal. Its "Dismiss result" action
uses useAcknowledgeMemoryMaintenance and the shared JSON client, addressing the receipt by id so it cannot
acknowledge a newer run accidentally. Acknowledgement changes feedback visibility only.
No configured embedding or categorization model means the matching start controls stay disabled.
/projectsmanages registered Projects and their plugin panels. ItsGET /projects/summaryread uses the coreprojectSummaryprojection only after account authorization and ordering. Project cards retain admin-only member totals/samples, granted plugin indicators and budgeted host/managed branch reads; unavailable indicators or branches do not remove the register. Empty authorized rows produce no cards./settingsis the administrator settings surface./usersis the administrator account and access surface./accountis the signed-in account's settings surface./p/<plugin>/<...rest>hosts an enabled plugin page or plugin settings section. The rest segment is optional (app/p/[plugin]/[[...rest]]/page.tsx).- Editor and statistics pages use the enabled plugins'
/p/editorand/p/statsroutes. The old/editorand/statsbookmark redirects have been removed.
Settings and Account use the parallel @pageOverlay slot. Client navigation presents the destination over the current page, while a hard load, refresh, or external link renders the same surface for the canonical route. The canonical settings/page.tsx and account/page.tsx routes own the addresses; the overlay slot owns their presentation. The catch-all slot clears an overlay when navigation leaves the intercepted route.
SettingsView composes the core administrator deck. useSettingsSection(pluginEntries) owns remembered-section hydration, explicit URL precedence, history listeners, row-anchor consumption and legacy plugin-section forwarding. Its mount effects retain their order: localStorage is read before the actual address, and canonicalization waits one commit. It rewrites only cat, preserving unrelated query parameters and fragments; legacy plugin ids wait for the live listing, then use pluginSectionHref or retire to System. Listener cleanup follows the deck's lifetime.
settingsMetrics renders readings from the deck's existing config, system, model, plugin and Data-only log queries without fetching. Missing readings retain their existing placeholders and Voice has no metric rail. ModelCatalogSection owns provider grouping, filtering and fold presentation, while SystemSettingsSection renders instance records and lazily loaded diagnostics. Both receive the deck's existing queries and drafts; one-time seeding, autosave groups, restart confirmation and the model-limit modal stay in SettingsView, outside retained panels. PageToolbarScope and React Activity retain visited forms while only the visible panel contributes toolbar controls. Sections without controls still pass no toolbar props and keep the empty portal slot.
web/modules/settings/usePluginDetailRoute.ts owns PluginsSection's ?plugin= selection,
initial hydration and popstate listener. It waits for the installed catalogue before replacing a
missing, removed or disabled deep link, preserving unrelated query parameters and clearing the old
section hash. Opening and closing a detail replace the same history entry; no plugin parameter keeps
the list selected. The hook performs no catalogue fetch and removes its listener on teardown.
web/modules/account/apiTokenPresentation.ts owns limitValueText and sliderRange for token usage
and the limits modal. Counts use the active locale, costs retain the existing micro-USD conversion,
and null reads as unlimited. Stored values widen the slider bounds rather than changing the value.
Usage totals use the existing interpolate helper with all six formatted fields; the byte-pinned
web/lib/apiTokens.ts contract remains presentation-free.
Feature views live in web/modules/. Shared application components live in web/components/, the browser client and cross-cutting helpers live in web/lib/, and browser tests live in web/tests/. Do not import daemon or database runtime modules into the Next.js bundle. Public database declarations come from elowen-db through type-only imports. Import wire contracts only as types through src/shared/wireContract.ts; it has no runtime imports or runtime exports. The only module import is the canonical unaliased named import type { ConnectionState, RetryProgress } from the shared retry declaration for local wire field annotations. Its sole external type query is import('@earendil-works/pi-tui').ProgramStatus, used by BrainStreamControl to select PI's public state and kind without copying the native vocabulary. Both toolchains resolve the installed PI declaration; the query erases completely, so it loads no PI runtime in the browser. Other type queries, module imports, runtime loads and noncanonical re-exports remain forbidden by the syntax-based wire contract guard. web/lib/types.ts re-exports shared wire shapes such as brain cards and REST/SSE rows. Plugin configuration declarations remain owned by src/plugins/manifest.ts; the browser may import that declaration only as a type, while the manifest's one field-type list drives runtime validation. Browser implementations that cannot share Node.js runtime code must be kept in parity tests. Runtime-mirror example: formatCompactCount in src/shared/displayFormat.ts is the canonical compact count (nonfinite/negative renders '0'), used by every CLI surface; formatTokens in web/lib/format.ts delegates to the byte-identical web/lib/displayFormat.ts browser mirror (tests/contract/webMirrorContracts.test.ts enforces both contracts). Exact localized counts are formatUsageCount in web/lib/format.ts, used by the account chart and the dashboard pulse cards. web/lib/runtimeLimits.ts is the web mirror of the daemon's runtime-limit defaults and clamp bounds (packages/db/src/config/codecs/store/config/limits.ts), pinned as text by runtimeLimitsParity.test.ts. The Runtime settings modal, the Tool loading threshold slider and the app-wide Toast duration all read it there, and a new runtime knob is added on both sides.
The shell-wide BrainChatProvider retains conversation state across route changes. brainChatUsage.ts owns its usage state, ordered stream epoch, latest-started REST read fence and live status sampling. Each REST fence captures the bound session and connection generation as well as the stream epoch; a late sample cannot publish after navigation, a newer read, snapshot or compaction. A same-stream session rebind clears the previous conversation's usage. Snapshots take the last usage-bearing step or idle in daemon order; a snapshot with no usage-bearing event fences older reads and refreshes usage from the newly bound conversation. Read-only child views use their stream alone. The live /brain/status sample runs every three seconds only during a generating turn with a mounted chat surface in a visible tab. A tab returning to the foreground refreshes immediately. Sampling is paused when no chat surface or generating turn is active, when the tab is hidden, and while viewing another session. BrainChatSurface registers the visible surface, while stream step and idle events still own usage at request boundaries. The statusline is the live-rate consumer.
The hook exposes refreshUsage(session = sessionRef.current): Promise<void> for usage-only REST refreshes. It captures the stream epoch, latest-started read id, connection generation and requested session synchronously before calling the current status reader, then publishes usage only through the existing freshness check. BrainChatProvider uses it after a snapshot without usage, sub-agent settlement, compaction and opening Statistics; the hook uses it for live polling. Call refreshUsage() and handle its rejected promise at the caller. A failed read retains the last usage value; it never substitutes zero or retries on its own. The injected status reader retains its existing unknown-control recovery. Visibility gates only polling, so event-triggered refreshes still run in hidden tabs. Each invocation performs one status read without caching or cancellation. Initial hydration and settled-state refreshes keep their existing combined reads because they also publish non-usage fields.
useBrainChatLifecycle.ts owns the provider-lifetime open/compose bridge, unload, visibility, wake-up, silence watchdog and teardown subscriptions. It uses the provider's existing binding and stream refs without reconnecting on route changes or streamed tokens. Every pagehide reports hidden visibility only: a closed tab and a locked phone are indistinguishable, so neither sends a stop mutation. Transport detach preserves the binding for GET recovery; the daemon's existing idle reaper owns abandoned runtimes. Revive interrupts stale reads through the existing serialized stream controller using the current silence limit; only an observed offline-to-online edge can renew an exhausted retry episode. Watchdog configuration changes re-arm only the watchdog; provider teardown stops the stream and removes all browser subscriptions. Its silence floor comes directly from the guarded RUNTIME_LIMIT_BOUNDS browser table, with daemon parity still tested. History and stream setup both import CHAT_HISTORY_PAGE_SIZE directly from its browser owner.
The dashboard HomeComposer calls the same BrainChatProvider.startNewConversation(opts?) action as chat's New chat controls, which pass the project chosen in NewConversationMenu. Called without a target (/new, or a chat opened with nothing to resume) the conversation stays in the daemon's default. The dashboard asks up front: a compact destination picker in the composer offers the new-conversation menu's own list from GET /brain/execution (projects only: no destination runs without a project, an administrator's included), preselects the last choice remembered on this device (usePersistentState, key elowen:home-destination) or else what the daemon picks for a new conversation (the first project of the account's order); the picker opens the shared project menu body (ProjectMenuContent, with the same search as the chat pickers), and it passes the choice as target for both Send and Call. startNewConversation({ message, target }) creates the conversation, moves it to target through selectProjectExecution (the authorized POST /brain/execution the chat pickers use) before anything else, then sends through the controller's shared elowenClient.brainSend path before the caller navigates to /chat. A refused move rejects like a failed send and sends nothing. Until the targets load, or if they fail, the picker is absent and the daemon's default stays. No previous-conversation draft is written. The dashboard has no attachment control. Creation captures its confirmed session/client/generation binding and checks that binding after asynchronous status hydration, cache invalidation and send completion. A superseding conversation rejects the older operation explicitly; its initial message never uses the newer binding, and its completion cannot consume the dashboard draft. Optional status hydration failure still permits sending through a current confirmed binding. Creation and send failures reject to the caller, which keeps its text and displays the localized send error; a disabled submit control and in-flight guard prevent duplicate creation. Apart from the cached execution-target read for the picker, nothing is requested until a message or call is started.
Terminal settings, their palette and permission snapshots are type-only imports
from wireContract.ts, also used by their daemon store owners. showThoughtsCli
is required because sanitized settings always include it. Permission revisions
belong to the HTTP snapshot, not stored permission maps. The skin catalogue likewise
uses the same runtime-free wire types on both sides; runtime parsing remains in
the skin owner. Plugin config drafts use the importless, byte-pinned pluginNumber
mirror for step alignment, while min/max/reset and visible validation errors retain
their existing behavior. Asset names and problem reports share the single browser
nameGrammar mirror. webMirrorContracts guards these copies, and dtoMirror
checks shared type identity instead of parsing obsolete local interface copies.
Backend-for-frontend proxy
Browser requests use the same-origin /api base through web/lib/elowenClient.ts. The catch-all route web/app/api/[...path]/route.ts is the BFF for REST and SSE:
- It reads the httpOnly session cookie on the server:
elowen_session, or__Host-elowen_sessionwhen the request arrived over HTTPS. - When a personal API token is presented as a
Bearerauthorization header, it is forwarded as the daemon bearer and wins over the cookie. Otherwise, when a cookie is present, it adds the daemon bearer authorization server-side. - It forwards the request body as a stream, preserving binary uploads and long-lived SSE responses.
- It forwards only the allow-listed content and range headers. A browser-supplied
Authorizationheader is forwarded only when it carries a personal API token; a login token presented by a client is never forwarded. - It rejects unsafe decoded path segments and re-encodes each segment before forwarding.
- It checks same-origin policy for mutating requests.
- It propagates client cancellation upstream. Ordinary JSON reads get a 12 s browser deadline and an independent 20 s upstream deadline, returning
504on timeout without logging the caller out. The browser deadline covers both headers and JSON body consumption, including error bodies: expiry remains504with codetimeout, while caller cancellation preserves its original abort error. Timeouts are not retried by React Query. Mutations have no automatic deadline or ordinary retry, so a timed-out send cannot be replayed accidentally. The only safe replay exception is the BFF proving its daemon connection was refused before delivering the write. Explicit read-like POSTs can choose their own bound (search rank 12 s; search Ask 18 s; provider test 30 s). SSE (/events,/brain/conversations,/brain/streamand requests accepting event streams), binary uploads/downloads and long-running agent runs retain only the caller signal. A closed caller returns499upstream. An absent plugin stream has no event channel to proxy. - It removes upstream
Set-Cookieheaders because the BFF owns the browser cookie. - Ordinary
401responses never write or expire browser credentials. They may describe an old request cookie after another tab rotated it; Set-Cookie cannot condition deletion on the browser's current value. Upstream cookies are still removed. A current-generation client refusal closes only its local gate, without POSTing logout; explicit logout and dedicated committed security routes retain cookie-clearing authority. An API-token401cannot mutate the browser credential. - A connection-level refusal or reset returns
503with{ error: 'daemon_restarting', replaySafe };replaySafeis true only forECONNREFUSED. A real daemon 5xx passes through unchanged. The proxy continues forwarding only nginx'sx-real-ip; address trust stays insrc/api/clientIp.ts.
elowenClient owns authenticated JSON/raw recovery, including
ElowenUiRuntime.api through elowenClient.api. Eligible safe GET network
TypeError and transient 5xx responses use one finite canonical request episode;
deliberate 503 degradation, 504 timeouts, 4xx and JSON/schema failures do not enter
that read scheduler. Policy count/timing is pinned, with canonical server minima
and response-body/controller/listener cleanup before repeated dispatch.
Typed daemon_restarting responses use one shared, auth-generation-scoped health
owner with a 20-second total cap including policy bootstrap. Safe reads or
explicit replaySafe: true permit at most one physical replay. An already-owned
read cannot start a second health owner; the post-health replay cannot start
another scheduler. Mutation network failure never retries. Ambiguous reset writes
stay unconfirmed even if recovery expires; they require verification, not replay.
The original mutation keeps its caller-only signal and no automatic read deadline.
Generation fences cover response, wait and replay: a late old-account 200/401
rejects 401 without clearing a replacement login. React Query has retry: false
to avoid multiplying the owned maximum. End-of-recovery invalidate-all is removed;
existing scoped refresh and cancel-before-refresh behavior remains.
elowenClient.previewToolDeferral(runtime) is the Settings tool-loading editor's read-like POST consumer: it sends the exact draft to /config/tool-deferral with the shared 12-second JSON deadline and validates every catalog group and tool before rendering. The response type and schema live beside this client method; reason strings remain open so unknown server explanations are shown verbatim. Empty catalogs stay empty. A malformed or refused preview clears the catalog and disables Save; first loading also disables Save, and the modal's generation guard prevents older replies replacing the current draft's answer. No local policy calculation or extra polling is added. The global disabled explanation renders once as visible text.
Finite resumable upload requests
web/lib/projectUploads.ts::uploadProjectFile keeps the existing UI-runtime
signature, Chat's conversation destination and Editor's explicit authorized
Project/directory. Before open it awaits the strict current-auth connection
policy. It uses the existing direct same-origin BFF fetch, not recovering REST
or a new raw-response API. No call means no requests or policy read.
Each consecutive no-progress part span owns one canonical finite request episode:
original plus maxRetries, pinned policy, deterministic exponential timing with
60000 ms cap and eligible canonical Retry-After minimum. A successful response
without progress or an eligible offset conflict consumes that same budget. Only
a validated safe-integer durable received strictly advancing within the submitted
[offset,end] ends it and starts the next progress span with a fresh policy.
Fractional, backward or beyond-part offsets terminate without commit permission.
Each attempt reuses the SAME immutable Blob/offset until progress. Lost ACK is
reconciled by the server's authoritative offset, not inferred mutation success.
Each attempt makes one fetch. HTTP 429/5xx and eligible offset conflicts may
retry; 401/403/404 and 409 without received terminate. Observed Retry-After
survives response-body loss; JSON/programming errors do not become network retries.
The existing abort/deadline signal cancels requests and waits. Focus, failures
and policy revisions cannot renew a no-progress budget. Open and commit POSTs
are never replayed, including unknown outcomes. Existing best-effort staging
DELETE in finally, 300-character refusal reasons, byte progress, part-bounded
memory and UI feedback remain unchanged. Focused fake-network tests do not
establish browser-flow or live upload acceptance.
Resumable chat attachments keep their sequential open/part/commit upload protocol and bounded part retries. readRefusal in web/lib/projectUploads.ts parses each refused response once for all three phases, trims and bounds the server reason to 300 characters, and carries the raw received value to the finite part owner, which validates its safe-integer and submitted-range bounds before resumption. Non-JSON refusals carry no reason; their HTTP status still decides whether the existing part retry applies. This consolidation adds no requests and changes no upload admission or cancellation behavior.
The sole ToastProvider subscribes to this recovery state and displays one polite, non-error loading card until connectivity returns. Pass a caught error as toast(message, 'error', { error }) to get a neutral verify-before-retry message for an unconfirmed reset write; toast.promise passes the cause itself. Plugin toggle/install/update use this shared adapter. Shared autosaves use unconfirmed, not saved or error, and AutoSaveStatus offers a neutral verification note and Retry. Closing or completing a draft remains blocked until its unconfirmed save is resolved, just as for a refused save. A reset write stays unconfirmed even when health recovery itself expires. Login, logout and local or sibling-tab identity transitions advance the existing auth generation, so an old waiting request never replays under a changed cookie identity. Plugin bundles obtain these hooks, components and types only through ElowenUiRuntime and elowen-plugin-ui-kit; they must not import host web/ files. No success is claimed for an unconfirmed write.
web/tests/e2e/specs/restart.calm.e2e.ts verifies this seam with a real fake-daemon listener outage in Czech, Slovak and English at 320 and 1440 px. It follows the named plugin switch when disabling closes the detail, asserts the refused-write 503 and successful 202 replay, and waits for finite toast entrance animations before checking the translated text, containment and screenshot. Only browser resource diagnostics for the deliberately broken SSE transport and the typed refusal are expected; application console errors and page exceptions still fail the test.
Login, TOTP completion, enrollment confirmation, impersonation, impersonation return and Microsoft SSO callbacks mint session cookies only from a daemon response with a 64-character hexadecimal token, a positive whole-day tokenTtlDays whose seconds conversion remains a safe integer, and a required future ISO sessionExpiresAt. readDaemonSession in web/lib/proxy.ts is their single parse-and-validate boundary. Cookie Max-Age is the floored remaining absolute lifetime, not another full TTL: returning from impersonation cannot refresh the original trust anchor. Missing or malformed expiry has no legacy branch. Invalid successful payloads fail closed with 502 for POST routes or the existing SSO failure redirect; they never mint or clear an authenticated session cookie. The Microsoft callback also accepts only a same-origin relative next path.
Password login returns the shared BrowserLoginResult discriminated union: authenticated without a bearer, or totp_required with only expiresAt. On a validated upstream 202, the BFF stores the opaque challenge exclusively in elowen_totp_pending (__Host- on HTTPS), HttpOnly, SameSite=Lax, Path=/, Secure on HTTPS, with at most five minutes of remaining lifetime. This cookie is unauthenticated flow state, never a trusted-device credential. POST /api/auth/login/totp accepts only {code}; the BFF injects its pending cookie into the daemon request. There is no challenge URL, JavaScript storage, query cache or pending-state read endpoint. Refresh may restart password login. Wrong-code 401 does not clear an unrelated authenticated cookie or emit the client's global sign-out event. All dedicated auth responses are no-store; rate-limit refusals preserve Retry-After as ElowenApiError.details.retryAfter.
POST /api/auth/login/cancel cancels only the exact pending credential, clears only its cookie, and reports upstream failure rather than pretending cancellation committed. Microsoft SSO start cancels pending proof before opening its own flow; final authentication clears pending state. authenticatedSessionResponse projects only status, user, TTL and expiry and shares cookie rotation across password login, code completion and enrollment confirmation. Narrow BFF handlers for enrollment confirmation, disable and session revocation prevent a rotated bearer leaking through the catch-all and clear authenticated cookies only after validated success or a server-proven committed revocation. A presented API token cannot select the cookie identity for these authenticated security writes; the daemon still owns human/session/revision authorization.
elowenClient exposes accountSecurity, enrollment begin/confirm/cancel, disable, paged loginSessions(cursor?), revoke and administrator reset through the existing request seam and shared wire DTOs re-exported by web/lib/types.ts. Account consumes enrollment directly into component-local state: no enrollment mutation or query holds its seed. useAccountSecurity and useLoginSessions cache only seed-free status/session projections. useLogin resets authenticated queries only after the final authenticated branch; useCompleteTotpLogin does the same for code completion. The existing authentication generation is a stale-401 fence, not proof of identity: every password/code attempt advances it synchronously before its request, protecting login from older anonymous config reads that answer while it is still in flight. Final authentication advances it again to fence reads started during that request. A pending 202 neither commits an authenticated query reset nor opens the gate; it does not add a response-time generation advance. The instance-wide public /setup probe used by LoginGate and /auth/sso/providers list loaded once by LoginForm keep the same JSON parsing and 12-second request deadline but use one fetch without session lifetime or authenticated retry-policy bootstrap: a concurrent anonymous /auth/me refusal must not invalidate a valid first-run, licence-state or SSO-discovery answer. Protected requests retain their generation fence and retry owner. A separate local login sequence rejects superseded responses as AbortError, including a shared-transport refusal after that flow or authentication generation changed; current-flow authentication and transport errors retain their original classification. Ordered login/cancel writes ensure cancellation clears a pending Set-Cookie that was already in flight. Authenticated identity transitions invalidate delayed login responses. OTP-consuming writes have no automatic retry, even after an explicitly refused connection; reconnect may restore connectivity but returns an unconfirmed-write error instead of consuming another code.
A committed security mutation whose live-transport cleanup fails returns 503 {error:'auth_security_cleanup_failed',committed:true}, not a rollback or success. authRefusal validates this exact status and literal marker with Zod, projects only the two safe fields and keeps no-store. Confirmation and disable already revoked the request's original credential, so their BFF clears session, pending-login and impersonation cookies even on this committed error. Revocation lacks a current field on this failure path: only then, its BFF makes one bounded, no-store /auth/me read with the original bearer and clears cookies only on 401. An unavailable probe preserves cookies and the committed error, without asserting session liveness; the UI must re-read state. A target administrator reset does not clear the actor's cookie. The existing ElowenApiError.code and details.committed === true carry this result to Account/Users without a new browser predicate or retry; the mutation must not be resubmitted automatically.
The existing AuthTransition transport also coordinates enrollment-confirmation credential rotation through beginAuthTransition(true) / finishAuthTransition(...,{refresh:true}) in the leaf token owner. Its transient localStorage message contains only a transition id, phase and refresh:true, never a credential or identity proof. All tabs fence old 401s before the write and again on completion; while a transition is active, ordinary 401s cannot close the gate. This is not an identity switch: an already-open Account stays mounted at the same URL, preserving its local code/error handling, while streams pause and old reads are cancelled without clearing same-account cached data. Commit and rollback both force a fresh /auth/me, then invalidate other reads without dropping their last data. Success can display enabled/trusted state; a refused code keeps the live form, and a committed cleanup failure with a revoked cookie honestly closes the gate. An anonymous tab stays closed until that daemon proof succeeds. Impersonation and return keep the existing teardown/navigation behavior; no parallel coordinator or device credential is introduced.
LoginGate resolves an unavailable /auth/me through the public setup probe only while checking, open or showing login. After it selects the licence or setup screen, that screen owns its probes; the gate must not start a second request from the same stale 503. LicenceLocked owns its serial status polling until activation returns control to the gate. LoginGate.licence.test.tsx checks that handoff with a delayed setup response.
During an identity switch, LoginGate keeps the existing loading state and closes account streams until the requested landing pathname is committed by the router. A successful /auth/me read alone does not reopen the shell: doing so before asynchronous navigation completes remounts the previous page under the new identity and triggers its queries and stream-open invalidations. The same landing gate applies to commit and rollback, including a custom landing path.
Requests without a cookie remain tokenless so first-run onboarding can reach the daemon. The daemon is the authorization authority. Client code must render server decisions rather than reproduce account, Project, plugin, or tool-policy checks.
The authenticated core /api/events feed remains open in hidden tabs, switching
to ?presence=0 without counting the tab as online; the separate conversations
feed retains its own controller. Presence replacement calls context.notReady()
synchronously before aborting the old fetch, serializes replacement within the
same attempt/count and requires a new handshake plus cache hydration. A stalled
hide/show gap cannot count toward 60 seconds of continuous readiness. Focus,
duplicate online and ordinary visibility wakes cannot renew exhaustion; only an
actual offline-to-online edge or explicit manual recovery can restart an episode.
The /users projection and its user-presence invalidation remain authoritative;
/activity/presence still describes work, not web activity.
Authenticated browser policy and single-attempt requests
web/lib/elowenClient.ts is the sole authenticated in-memory browser policy
source, separate from administrator config and React Query:
loadConnectionPolicy(signal?: AbortSignal): Promise<RetryPolicy>
getConnectionPolicy(): RetryPolicy
acceptConnectionPolicy(value: unknown, issuedIn: number): RetryPolicy | null
apiOnce(path: string, init?: PluginApiRequestInit): Promise<unknown>
GET /connection-policy returns raw RetryPolicy through same-origin cookies,
no-store and the existing 12-second JSON body deadline. Canonical validation,
immutable accepted values and increasing revisions are required. The getter
throws without a validated value for current token.ts authentication generation;
acceptance returns null for a stale generation. Missing, invalid or unreadable
policy fails explicitly, without config/default/old-account fallback. Concurrent
cold calls can fetch independently; each caller's abort affects only its own
bootstrap. New revisions update the source immediately, while a running controller
retains its captured revision until its next explicit episode.
The global SSE payload is { type: 'retry-policy', policy }, not raw policy:
useElowenEvents validates the envelope with Zod plus the canonical guard and
passes event.policy to the source. There is no raw-SSE compatibility decoder,
chat policy frame or snapshot policy lane. Consumers await
loadConnectionPolicy(lifetimeSignal), fence their generation and inject
getPolicy: getConnectionPolicy into the shared controller. Authentication change,
logout and unmount dispose the owning lifetime; no second policy/auth store exists.
The /events owner subscribes before its strict policy read, then gates both policy
and ordinary/voice delivery with the originating browser credential. Revocation or
absolute session expiry closes that stream and releases its policy subscription;
policy revisions and Project authorization never replace session assurance.
OTP-consuming confirmation, disable, reset and challenge requests never replay,
even if a restart response advertises replaySafe; preauthentication 401s retain
their typed challenge/refusal body without clearing an authenticated generation.
apiOnce is a named core-only export, not an elowenClient property, plugin/UI-kit
option or retry flag. PTY/desktop ticket owners import it when an outer controller
already owns the complete budget. It shares existing apiRequest/req/send
cookie authentication, JSON normalization, typed error and generation boundaries,
but dispatches at most one application fetch. It performs no policy bootstrap,
safe-GET retry, health wait, recovery feedback or replay, even for
daemon_restarting with replaySafe: true. A current-generation 401 clears only
that generation; old 200/401 cannot clear a replacement login. GET keeps its
existing 12-second body deadline and caller cancellation; non-GET has no automatic
read deadline. Other requests keep their existing owner. The outer ticket caller
must migrate separately; exposing this seam alone does not establish PTY/desktop
integration.
The existing ElowenApiError exposes required readonly
retryAfterMs: number | null. Shared req populates it on non-OK HTTP responses
only through canonical retryAfterMs(response.headers.get('retry-after')).
Seconds and HTTP dates retain the shared parser's semantics: zero stays zero;
missing, invalid or unobservable hints stay null. Locally constructed errors with
no observed HTTP hint initialize null; the constructor's fifth argument is the
already parsed number/null, not a raw header. This is metadata, never retry
eligibility, a policy flag or permission to replay. Outer ticket owners combine
an eligible server minimum with their policy/domain minimum; no body/prose parsing
or extra request owner is introduced.
Global and conversation-list feed ownership
useElowenEvents creates one finite global controller and one finite
conversation-list controller after authenticated policy bootstrap.
readDaemonStream performs each single fetch/body lifetime directly, never native
EventSource or retrying REST. onBytes refreshes only the watchdog. A complete
canonical keepalive comment or valid JSON data frame starts existing domain cache
hydration; only successful hydration under the current signal calls ready().
Headers, partial/invalid bytes and mere connection-open never prove readiness.
Input/parser failures propagate through the hook error boundary rather than being
silently truncated or converted to retry success.
Both feeds omit optional maxBufferLength, preserving their former native
behavior: JavaScript text accumulation is unbounded until a frame terminator.
There is no invented 1 MiB/16 MiB cap; a fragmented frame larger than 1 MiB is
covered by regression tests. Reader cleanup, watchdogs and controller listeners
retain their existing owners. There is no new polling cadence; the existing
10-second Project/load sampling remains unchanged.
Host-authorized computer-connection frames validate projectId, targetId, UUID
authorityId, numeric generation/sequence, connected state and epoch-ms
observedAt. Each fetch owns one observation epoch; stale authority, generation
or sequence cannot overwrite its accepted observation. A fresh push cancels older
['projects'] reads before invalidating that prefix, including target/assignable
queries. It never patches private state, invents remote retry counters or derives
disconnection from a failed list read. Async refreshes recheck auth lifetime.
Without an authenticated mounted bridge no feed or policy work starts. Active
feeds retain existing sockets/cache-refresh cost; no controller adds another
poller. Focused tests use actual transports with fake responses, including ticket
paths, but do not prove browser flows or complete consumer migration.
Shared browser retry and stream reader
web/lib/reconnect.ts::createReconnectController(options) is a thin
createRetryController binding with finite mode: 'connection'. Callers supply
target, validated getPolicy and attempt(context); optional lifetime signal,
absolute monotonic deadline and progress callback retain the shared contract.
The browser binding owns no default policy, timing formula or jitter. Its API is
start/restart/stop; the former now/retry/succeeded/onActive/baseMs/maxMs/random
contract is removed, without a compatibility overload. Global and conversation-list
feeds consume these primitives as described above. Chat, PTY and desktop callers
retain their own migration and acceptance gates; reader/controller tests alone do
not establish those flows.
An attempt spans the whole transport lifetime and calls context.ready() only
after its domain handshake or applied snapshot. Focus and health wakes call
idempotent start(), including after exhaustion. Explicit manual Retry, an
observed offline-to-online edge, or one foreground return can permit restart().
useRevive publishes hiddenMs, networkRecovered and foregroundReturned.
Hidden visibility, pagehide and freeze arm one return; visible visibilitychange,
pageshow (persisted or not) and document resume consume it together. The first
pageshow/resume also covers a restore with no observed hide. Duplicate signals
cannot renew again until another hide, independent of the one-second health-event
coalescing window. Foreground and network edges bypass that coalescing so focus
cannot swallow recovery. Hidden lifecycle events do not publish a foreground edge.
Chat and both useElowenEvents feeds restart exhausted or stale connections on
that return; their ordinary health checks retain the episode budget. Feed readiness
still waits for scoped cache hydration, never a chat-history refetch. Retained PTY,
desktop-control and VNC owners also restart exhausted episodes on return, through
their existing retry action; healthy/connecting sockets, refused or ended targets,
takeover and input replay remain untouched. Those adapters have no new silence
heuristic. Every subscription is removed with its owner. Authentication owners
stop their controllers on identity change. Policy bootstrap remains caller-owned,
without a fetch/default fallback. The shared package retry controller itself stays
unchanged: start() is still idempotent after exhaustion, and all renewed episodes
use its existing finite policy and serialized cleanup, not a second retry loop.
readDaemonStream(url, { signal, maxBufferLength?, onFrame, onBytes?, onKeepalive?, onResponse? }) in web/lib/daemonStream.ts performs one direct GET for the
caller's same-origin endpoint with cookies, no-store and an SSE Accept header.
It never uses native EventSource or the retrying REST recovery helper.
signal is required. A supplied reassembly bound must be a positive safe integer;
omitting it leaves JavaScript reassembly unbounded. Chat and global-event adapters
retain their former native EventSource behavior without an invented 16 MiB/1 MiB
ceiling; an unterminated block can therefore keep growing in memory. Adapters with
an existing explicit ceiling retain that bound when migrated. The canonical
parseSse and its byte-identical web mirror preserve the existing frames/rest
result and optionally report complete blocks with colon-prefixed comments.
onKeepalive is awaited once per qualifying block, before data-frame callbacks
from that decoded batch; callbacks are generation/signal fenced. Fragmented or
malformed comments and arbitrary bytes do not invoke it, and no dummy data frame
is created. An empty conversations feed's completed : connected/: ping block
can therefore trigger domain cache hydration; only after that work completes may
the consumer call context.ready(). The reader never marks readiness itself.
onBytes remains every nonempty chunk for byte liveness only, and frame callbacks
are awaited serially. Headers or arbitrary/partial bytes never prove readiness. EOF returns retry/closed; fetch/read network
TypeErrors return retry/network. HTTP 401/403 block as auth_required/forbidden;
408, 429 and 5xx request timeout/rate_limited/unavailable retries with the canonical
Retry-After hint. Other refused HTTP responses block. Missing body, wrong content
type, overflow and unexpected callback errors reject instead of being retried.
Attempt abort and authentication clear/transition cancel the owned reader;
generation checks fence response and frame callbacks. Completion waits for reader
cancellation, releases its lock and removes auth/abort listeners. Domain watchdogs
end an attempt through its own abort controller and classify timeout separately
from controller lifetime cancellation. startStreamWatchdog keeps its existing
silence bounds, but reports each unchanged stale timestamp once; fresh bytes
rearm it. It grants no retry budget or readiness. These primitives introduce no
second stream, poll or automatic request replay; browser-flow acceptance remains
separate from their unit contracts.
Browser transport authority comes from the authenticated credential, never from Origin or plugin arguments. Core SSE owners use browserStreamGuard to gate delivery against the original login token, revalidate after committed auth-security invalidation and on the existing heartbeat; revoked or expired credentials close without cancelling admitted model work. Browser plugin tickets and final WebSocket admission share an account generation. Any individual browser-session revocation invalidates that account's browser tickets and disconnects its browser plugin sockets and browser voice/dictation peers; other valid logins reconnect without another code. API, paired-computer and public integration credentials retain their own policies. No additional connection registry or timer is introduced.
Authenticated plugin SSE, both namespaced and root-mounted, uses the shared browserStreamGuard after handler admission and before every frame, including plugin-owned heartbeats. Revoked or expired login credentials abort both the response stream and the handler signal; teardown releases the credential subscription. Still-valid browser logins resume after revalidation, while API/device credentials and public hooks retain their separate authority. Desktop control/action streams consume this seam without an additional core timer.
Shared cookie and request boundaries
web/lib/cookies.ts::readCookieHeader accepts Cookie-header or document.cookie text, matches exact semicolon-separated names and treats empty/invalid percent encodings as absent. writePreferenceCookie mirrors locale/skin for one year at path / with SameSite=Lax; credential serialization stays separate. HTTPS uses only __Host- session names.
createDaemonPublicRead is server-only scaffolding for theme and skin catalogs. Each reader owns its public last-good payload and five-second failure backoff, uses no-store with a two-second timeout and React request deduplication. Parsing and lazy fallback stay reader-owned. Authenticated fetchForCaller remains account-bound and never shares user data.
Private send(path, init, timeoutMs) in elowenClient owns cookie-authenticated headers, composed cancellation and captured authentication generation. JSON and raw adapters receive response and signal; JSON body parsing uses that signal. A current-generation 401 outside an active auth transition closes the local gate without mutating cookies or issuing logout; a stale 401 cannot close a replacement login. JSON reads retain their 12-second default, writes have no implicit read deadline and raw callers choose their own. projects and projectRawBlob are consumers; no client Authorization header or public API is added.
clearImpersonationCookies(headers, secure) in proxy.ts appends expiry cookies for the opaque HttpOnly return proof and readable display hint, using the transport namespace without changing other headers. Login, Microsoft SSO callback, logout and stop-impersonate consume it; the callback separately clears its own flow cookie. The helper performs no request or authorization decision. These routes retain their response policy, cookie order and authentication checks.