Skip to content

feat: Organizations multi-tenant layer + client-abort server resilience - #11285

Open
yxyxy wants to merge 109 commits into
diegosouzapw:release/v3.8.52from
yxyxy:org+abortguard
Open

yxyxy wants to merge 109 commits into
diegosouzapw:release/v3.8.52from
yxyxy:org+abortguard

Conversation

@yxyxy

@yxyxy yxyxy commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR bundles two improvements to OmniRoute:

1. Organizations (multi-tenant) layer

  • Multi-user mode toggle, organization management, members, invitations, org-scoped combos/connections/quota.
  • New dashboard section Organizations and API routes under /api/v1/organizations/*.
  • Email verification for registration (P4).

2. Client-abort server resilience (bugfix)

  • Prevents a client disconnect (aborted / ECONNRESET on the incoming socket) from crashing the dev/prod HTTP server via an unhandled uncaughtException.
  • Adds httpClientAbortGuard (shared module + dev-server integration) that swallows client-abort errors while preserving genuine crash semantics.
  • Applied to sibling createServer call sites: apiBridgeServer, liveServer (WS), embedWsProxy.
  • Includes unit tests (tests/unit/httpClientAbortGuard.test.mjs, 8/8 passing).

3. i18n fix

  • Adds missing translation keys (auth.*, common.*, sidebar.*, cheaperInferenceSponsorBanner) to en.json / ru.json to resolve MISSING_MESSAGE IntlError console spam on the ru locale.

Test plan

  • node --test tests/unit/httpClientAbortGuard.test.mjs > 8/8 passing.
  • Manual: server survives browser client-aborts (bcache/HMR/WebSocket disconnect) without uncaughtException: aborted.

Base branch

Targets release/v3.8.50 (current active release branch).

@yxyxy
yxyxy requested a review from diegosouzapw as a code owner August 23, 2026 20:22
@diegosouzapw diegosouzapw changed the title feat: Organizations multi-tenant layer + client-abort server resilience feat: Organizations multi-tenant layer + client-abort server resilience [defer to 3.8.51] Aug 23, 2026
@diegosouzapw
diegosouzapw changed the base branch from release/v3.8.50 to release/v3.8.51 August 24, 2026 23:01
@diegosouzapw

Copy link
Copy Markdown
Owner

Re-homed to release/v3.8.51: v3.8.50 entered its release freeze, so the branch now belongs to the release captain and development continues on the next cycle. Nothing is wrong with this PR — it just needed a live base. No action needed from you; CI will re-run against the new base.

@diegosouzapw

Copy link
Copy Markdown
Owner

Hi @yxyxy — thanks for the enormous amount of careful work here; the fail-closed instincts (multi_user off by default, registration disabled by default, 404-without-existence-leak on cross-org models, scrypt + encrypted SMTP secrets) are exactly right. That said, we can't take the bundle as-is: it introduces a whole new identity model (users/orgs/SMTP/GitHub OAuth) that needs an owner-approved RFC before landing, it collides with migrations already on the branch (your 161_organization_quotas.sql vs the existing 161_config_audit_log.sql — the runner aborts on duplicate version prefixes), and it's currently conflicting with the base. Two asks: (1) please extract the client-abort guard (commits 83a4797/bcef7f0 + its test) into its own small PR — that part is clean, self-contained, and we want it this cycle; (2) let's move the org layer discussion to #11288 as an RFC (scope, rollout plan, migration numbering strategy) and re-base it on a fresh cycle branch. Happy to help structure the RFC.

yxyxy added 22 commits August 26, 2026 00:32
…in role, key linkage

PHASE 0 VERDICT approved (see _tasks/P0-architecture/PHASE0_VERDICT.md).
P1.01 user-model: src/lib/db/users.ts + migration 155_users.sql (CRUD, role enum, status).
P1.02 user-session-principal: src/lib/org/principal.ts resolveDashboardUserPrincipal (JWT sub -> user, legacy null).
P1.03 platform-admin-role: isPlatformAdmin/resolvePlatformRole in principal.ts.
P1.04 inference-key-principal: api_keys.user_id (migration 156) + setApiKeyUserId/getApiKeyUserId/getApiKeyUser (auth semantics unchanged).
Re-export users + key linkage in src/lib/localDb.ts (re-export only).
Tests: tests/unit/org-users.test.ts, org-principal.test.ts, org-identity.test.ts (18 passing).
Backward compatible: NULL org/user columns = personal; legacy keys validate unchanged.
…t-token independence

P6.01 model-parser: src/lib/org/qualifiedRoute.ts parseQualifiedModel — splits
`<organization>/<route>`; provider-prefixed ids (openai/gpt-4) stay personal.
P6.02 route-resolution: resolveQualifiedRoute — fail-closed (non-member/unknown
slug => null, no existence reveal); personal models unchanged.
P6.03 chat-completions: buildOrgRoutingContext (member => org scope + scoped
connectionIds; non-member => denied). Minimal scope-only wiring in
src/app/api/v1/chat/completions/route.ts: denied qualified route => 404 (no
tenant leak). Reuses existing combo/auto machinery (Invariant diegosouzapw#3) — no second
routing engine.
P6.04 management-token-compatibility: requireManagementAuth (token scopes) and
requireOrganizationAccess (membership) are independent; org role != mgmt scope.
Tests: tests/unit/org-qualified-route.test.ts, org-route-resolution.test.ts,
org-chat-qualified.test.ts, org-mgmt-token-compat.test.ts (23 passing).
All org unit tests 134/134 green. Backward compatible: personal models/combo/auto
behavior unchanged.
…o org connections

P7.02: in createVirtualAutoComboFromPrepared, apply
filterCandidatesByAllowedConnections(scopeAllowedConnectionIds) BEFORE any
other narrowing, so no downstream filter/fallback/failover can reintroduce an
out-of-scope connection. Personal scope (null) is the identity function; a
denied scope (empty set) yields an empty pool (fail-closed — never widens
back to personal).

Wiring:
- autoScope.ts: scopedConnectionIdSet(scope) -> Set<string>|null (null personal,
  empty denied, else org connectionIds); scopeAllowsConnection() fail-closed.
- candidateOverrides.ts: filterCandidatesByAllowedConnections() (fail-closed
  allowlist, mirrors existing exclusion filter).
- virtualFactory.ts: createVirtualAutoComboFromPrepared 6th param
  scopeOptions.allowedConnectionIds; public createVirtual passes it through.
- autoRouting.ts: createVirtualAutoCombo(state, combo, apiKeyId, scope) threads
  scopedConnectionIdSet(scope) + autoScopeKey(scope); namespaced virtualCombo.id
  via buildScopedAutoComboId() (P7.03 foundation).

Tests: tests/unit/org-auto-candidate-scope.test.ts (12 passing) — org auto returns
only org connections; personal auto unchanged; org A never leaks org B connections;
non-member fail-closed. All org unit tests still green.

Backward compatible: personal auto routing byte-identical (no scope => null => no filter).
P7.03 cache-scope: virtual auto-combo ids are namespaced by organization via
buildScopedAutoComboId() (implemented in P7.02 autoRouting.ts). Distinct orgs
get distinct combo/cache/cooldown/lockout keys; personal routes keep their
existing un-namespaced ids (backward compatible); denied scope isolated.

P7.04 failover-isolation: the existing intra-request auto failover selects
from the candidate pool it receives, so scoping the pool scopes failover.
filterCandidatesByAllowedConnections() (P7.02) restricts the pool to the org's
connectionIds BEFORE any downstream selection; org-A scope can never reach
org-B / personal / null candidates. Personal scope (null) is the identity
function — legacy failover byte-identical. Denied scope yields an empty pool
(fail-closed — never widens to personal).

Tests: tests/unit/org-auto-cache-scope.test.ts (5), org-auto-failover-isolation.test.ts (5).
All org unit tests green.
P8.01: full REST API under /api/v1/organizations/* backed by the P2/P3 data +
authz layers. Service logic lives in src/lib/org/orgApiService.ts (pure handlers
returning Response objects, fully unit-testable); thin route.ts files under
src/app/api/v1/organizations/ forward to it (Next.js App Router pattern).

Routes:
- POST/GET /                      create (owner=principal) / list principal's orgs
- GET/PATCH/DELETE /[id]          read / manage / archive (fail-closed by capability)
- GET/POST /[id]/members          list / add (manageResource)
- DELETE/PATCH /[id]/members/[userId]  remove / promote|demote (manageMembership)
- GET/POST /[id]/invitations      list / create (manageResource)
- DELETE /[id]/invitations/[token]    revoke (manageResource)
- GET /invitations/[token]        public token view (accept UI, no membership)
- GET /[id]/connections, /[id]/combos  org-scoped surfaces (P8.04/05/06 data)

Authn: resolveDashboardUserPrincipal (JWT cookie) → 401 when absent. Authz:
requireOrganizationAccess (P3) re-resolves org from membership (never trusts
client id), fail-closed 403/404. DB domain errors mapped to sanitized HTTP.
Also adds listUserOrganizations() to src/lib/db/organizations.ts + re-export
(re-export ONLY in localDb.ts).

Tests: tests/unit/org-api.test.ts (6) — create/list, 401 no-auth, 409 dup slug,
fail-closed 404 for non-member (no existence reveal), owner update/archive,
owner invite/revoke. All green.

Backward compatible: no existing routes touched; personal routing untouched.
yxyxy and others added 19 commits August 26, 2026 00:34
Expose githubOAuthEnabled via GET /api/settings/require-login; render a
'Continue with GitHub' button on the login page only when OAuth is configured
(gated server-side, mirrors the OIDC button). Add i18n key (en/ru).

TDD: tests/integration/github-login-button.test.ts (2). All pass; eslint clean.
Phase 08 Task 06. Add tests/unit/github-security.test.ts (3): idempotent
existing-link resolution, single-use CSRF state rejected on replay, expired
state rejected. Hardening is enforced by UNIQUE(provider,sub) + single-use/
expiring state + idempotent resolveOrProvisionGitHubUser from Task 04.

All pass; eslint clean.
… regression suite

Phase 08 Task 07 (compatibility checklist). Add tests/integration/github-task07.test.ts
(5): unknown state -> 403, server-bound redirect_uri, linking-attack UNIQUE
backstop, identity-mismatch safe-link never clobbers existing user, single-use
state replay rejected. Full Phase 08 = 27/27 green; eslint clean.
…cret-leak, rate-limit, CSRF, audit events, benchmark

Phase 09 (8 tasks). Adds 24 passing tests covering:
- T01 session lifecycle (sub+30d exp, expiry rejection, legacy admin resolve, tamper reject)
- T02 authorization matrix (isPlatformAdmin/resolvePlatformRole)
- T03 cross-user resource isolation (API keys + org membership scoping)
- T04 secret-leak guards (reset tokens hashed, OAuth/SMTP secrets encrypted + masked on read)
- T05 login brute-force guard lockout/reset/disable
- T06 CSRF token bound to session (cross-session replay rejected)
- T07 security-safe audit events on forgot/reset/github-callback (no token/code/password leakage)
- T08 benchmark: principal resolution + org dashboard load < 50ms avg/iter; guard map bounded

Also wires logAuditEvent into forgot-password, reset-password and github/callback
routes (fail-closed, metadata carries only safe flags).

All green; eslint clean.
…al+management tokens, org routing, inference keys

Phase 10 (8 tasks). Adds compatibility regression suite (4 tests) verifying:
- T01/T02 fresh/pre-multi-user defaults (multi-user OFF, registration disabled, requireLogin ON, OIDC OFF)
- T03/T06 personal inference API key (user_id NULL) still resolves without forced registration
- T05 management token mode retained (manage-scoped key preferred for internal use)
- T04 organization routing + role enforcement unchanged (non-member rejected, member allowed)

Documentation: COMPATIBILITY.md (migration/auth modes/registration/SMTP/recovery/GitHub OAuth)
and FINAL_REVIEW.md (security/migration/rollback/lockout-prevention). All Phase 9+10 = 28/28 green; eslint clean.
- Replace shadcn-style class names (bg-background, text-muted-foreground,
  bg-primary/text-primary-foreground) with the project's Tailwind v4 theme
  tokens (bg-card, bg-surface, text-text-main, border-border, bg-primary +
  text-white) so the form is no longer transparent on the canvas.
- Fix setTesting(false) call that referenced the state value instead of the
  setter (runtime crash in testConnection finally block).
- Add frontmatter title to ORGANIZATIONS-SECURITY.md so the MDX build passes.
Rebase onto release/v3.8.50 surfaced migration-version collisions: the
base branch added 155-159 (agentic_conversations, conversation_turn_nodes,
exclusive_connection_leases, call_logs_error_type, combos_org +
remove_mimocode_provider) on the same prefixes my org/auth roadmap used.

Renumber my migrations to free slots above the base max (172):
- users 155 -> 165 (must run before 162 login_identifier)
- api_keys_user 156 -> 174
- organizations 157 -> 175
- provider_connections_org 158 -> 176
- combos_org / remove_mimocode_provider 159 -> 177 / 178
- login_identifier 162 -> 179, login_identifier_unique 163 -> 180,
  email_unique 167 -> 181 (depend on users table, run after 165)

Add retroactive idempotency guards in migrationRunner for the new ALTER
migrations (users/login_identifier/api_keys_user/provider_connections_org/
combos_org) and update the stale unit tests that hard-coded the old
migration file paths and the old acceptInvitation contract (it returns the
invitation record; the membership join is performed by the accept-invitation
route via addMember).
… policy

The /login page's Register-control visibility depends on the registration
policy, but the GET handler required authentication — so an unauthenticated
visitor could never read it and the button never appeared (Task 02 regression).

GET now returns only the non-sensitive policy flags (multiUserEnabled,
registrationPolicy) without auth; POST (policy mutation) stays admin-only.
Added TDD guard (instance-settings-public-get.test.ts); updated existing
authorization test to cover the public-read / admin-only-mutation split.
…allback

- registrationConfig.ts (server): resolveRegistrationVisibility() reads
  OMNIROUTE_MULTI_USER_ENABLED + OMNIROUTE_REGISTRATION_POLICY (config-primary),
  falls back to instance_auth_settings DB row.
- registrationPolicy.ts (client-safe): isRegistrationAllowed() pure derivation,
  no DB/server imports (avoids better-sqlite3 in client bundle).
- useRegistrationPolicy hook now reads the public /api/settings/require-login
  endpoint (no auth) instead of the auth-gated /api/auth/instance-settings,
  so the Register button shows for unauthenticated visitors.
- require-login GET now returns multiUserEnabled + registrationPolicy.
- Fix transparent Users/UserDetail panels: replace non-existent
  --color-bg-surface with --color-surface (defined in globals.css).
- Remove dead registrationVisibility.ts; add unit test.

TDD: tests/unit/registrationPolicy.test.ts (2/2 pass), ESLint clean.
…, login/register toggle + styled form

- publicApiRoutes: mark /api/auth/register (POST+OPTIONS) as exact-method public route so the self-service signup is not gated by management auth (was 401 Authentication required).
- registrationService: resolveRegistrationVisibility() is now config(env)-primary, DB-fallback — env OMNIROUTE_REGISTRATION_POLICY overrides the DB row (matches the login-page button visibility).
- login/page.tsx + RegistrationForm.tsx: hide login fields in register mode, add 'Back to sign in' link, give registration inputs a surface background (no longer transparent).
- i18n: add backToSignIn key (en/ru).

Verified live on webpack dev server: POST /api/auth/register without cookie -> 201 with invite code, 400 without, never 401.
…ddMember with owner actor

acceptInvitation() (per P7.05) does NOT create the membership row; callers wire
membership creation at the API layer. The test modeled a non-manager membership via
the invitation flow, which left the member with no DB membership, so resolveOrgAccess
returned 404 instead of the expected 403. Use addMember({ actorUserId: owner.id })
to model a real non-manager membership.
…gate + post-flight consume

Previously org quota was declarative only: enforceOrgQuotaScope existed but was
never called from the chat execution path, and nothing wrote to the org quota
pool, so setting an org limit had zero runtime effect (cross-tenant isolation
gap, security-relevant).

- orgQuotaEnforcement.consumeOrgQuota(): post-flight accounting that writes the
  realized request count into the shared QuotaStore pool org:<orgId>, mirroring
  the legacy personal recordConsumption flow. Fail-open.
- Fix latent bug: enforceOrgQuotaScope/consumeOrgQuota called getQuotaStore()
  without await (async) so the store was a Promise and the call threw silently
  (swallowed by fail-open) — pool never read or written. Now awaited.
- Build a proper DimensionKey (poolId+unit+window) so the enforce read and the
  consume write share the same store key (previously the read passed a bare unit
  string, mismatching the write key — masked by the getUsage test seam).
- Wire into src/sse/handlers/chat.ts: pre-flight enforceOrgQuotaScope gate before
  handleComboChat (429 on block, fail-open on error) and post-flight
  consumeOrgQuota for non-streaming success, both threaded by routingScope.
  Personal/denied scopes are untouched (legacy path unchanged).

TDD: org-quota-enforcement.test.ts extended (7 tests) proves consume writes to
the real pool, a follow-up pre-flight blocks, cross-tenant isolation, and
fail-open on degenerate input. Org unit suite 239/239 green; ESLint clean.
…e isolation (P6)

The dashboard sends org combos as qualified routes (e.g. team1/combo:dev —
OrgModelPicker.tsx). But chat.ts only resolved combos via the legacy
getComboForModel, which returns null for qualified names, so an org member
requesting their own org combo got a fail-closed 404 — the explicit-org-combo
feature was non-functional.

Now, when the request resolved to an organization routing scope (a verified
member) and the model is org-qualified, chat.ts resolves the combo via
resolveComboInScope({name, organizationId}, routingScope.ctx) — the SAME
verified OrganizationContext the route already produced. This is the
execution-time isolation boundary: a member of teamA can only resolve teamA's
combo, never teamB's (fail-closed on orgId mismatch). Personal / denied /
unqualified models are untouched (legacy path unchanged).

TDD: org-combo-resolution.test.ts extended with a chat-path test asserting a
member resolves their own org combo and a cross-tenant member gets null.
Org unit suite 239/239 green; ESLint clean.
Documents that the branch introduces no new test regressions; the ~12
pre-existing unit failures on this Windows/git-bash host are environmental
(Windows temp-dir EPERM in isolateDataDir cleanup hook, command-line length
limit on single-invocation full-suite globs, aggregate-runner hang), not logic
regressions. Confirms org/quota/combo suites are green and ESLint clean for the
P6/P9 changes.
- Add admin API /api/admin/github-oauth (GET/POST) gated by
  requirePlatformAdminUser; persists via setGithubOAuthConfig and masks
  the client secret on the read path.
- Fix missing decrypt import in githubOAuthConfig.ts (callback would have
  thrown ReferenceError on token exchange).
- Add GithubOAuthSettings UI panel mounted in dashboard settings/general,
  mirroring SmtpSettings.
- Add login-page wiring already present reads githubOAuthEnabled from
  /api/settings/require-login, so the Continue-with-GitHub button now
  appears once an admin configures the OAuth app.
- Add registrationVisibility.ts (deriveRegistrationAllowed) referenced by
  the registration-visibility unit test.
- Add i18n keys (en/ru) and integration tests (7/7 green).
- Add email_verified column (users) + email_verification_tokens table (182/183 migrations).
- emailVerification.ts: one-time, expiring verification tokens (reuses passwordReset pattern, SHA-256 hashed).
- emailVerificationService.ts: dispatch via existing EmailService + renderVerificationEmail (already present, was orphaned).
- registrationService: when SMTP configured, new accounts start unverified + receive a verification token/email; without SMTP they are verified immediately (preserves P3).
- login: block unverified accounts (403 needsVerification).
- verify-email + resend-verification endpoints (fail-closed, anti-enumeration).
- Tests (TDD): pending, successful, expired/replayed, resend, login blocking, anti-enumeration (10/10). Auth regression + org suites green.
…/prod server

A browser closing the TCP socket during navigation/HMR/Back-Forward
cache (or nulling the request while the dashboard polls after login)
emits Error: aborted / ECONNRESET on the http.IncomingMessage. With no
'error' listener attached, Node re-throws it as an uncaughtException
that kills the whole server process — surfacing as a wall of
net::ERR_CONNECTION_REFUSED after login (see app.log uncaughtException: aborted).

- scripts/dev/httpClientAbortGuard.mjs: reusable guard that attaches
  req/res 'error' listeners (absorbing client-abort errors) and a
  process-level uncaughtException/unhandledRejection safety net that
  swallows benign aborts while preserving crash semantics for real errors.
- scripts/dev/run-next.mjs: install the guard in start() and attach
  per-request stream guards in the request listener.
- tests/unit/httpClientAbortGuard.test.mjs: TDD coverage for the exact
  abortIncoming crash signature + idempotency + crash-semantics preservation.
…, liveWS, embedWS)

Extends #fix-dev-server-aborted beyond the Next dev/prod server to the
other Node http servers that are equally exposed to a client aborting the
TCP socket (navigation/HMR/bfcache) while a response is in flight:

- src/lib/apiBridgeServer.ts — the OpenAI-compatible API bridge server.
- src/server/ws/liveServer.ts — the live-dashboard WebSocket daemon (20132).
- src/lib/services/embedWsProxy.ts — the embedded-service WS proxy (20131).

Each now calls attachRequestStreamGuards(req, res) inside its
http.createServer request listener, and installProcessCrashGuard() once
at start (idempotent process-level safety net).

The guard implementation is promoted to a single source of truth at
src/shared/utils/httpClientAbortGuard.mjs (importable from both the
Node-only dev server and the TypeScript servers; tsconfig allowJs:true).
scripts/dev/httpClientAbortGuard.mjs is now a pure re-export so the dev
server's existing import path is unchanged.

TDD: adds a test asserting the dev re-export is the SAME function objects
as the shared module (guards against future drift). Full guard suite stays
green (8/8).
Resolve MISSING_MESSAGE IntlError console spam on ru locale:
- auth: enabled, save, saved, saveError, adminRequired (GithubOAuthSettings)
- common: bypassProviderQuota, bypassProviderQuotaDescription
- home: recentRequests, recentRequestsEmpty, recentRequestsModel, recentRequestsTokens, recentRequestsWhen
- sidebar: logout, quickNavigation, quickNavigationTitle, openQuickNavigation
…races)

Corrects two duplicate closing braces left after merging the
LOCAL_ONLY_OAUTH_IMPORT_ROUTES block with isPublicExactMethodRoute
during the rebase onto release/v3.8.51.
@diegosouzapw

Copy link
Copy Markdown
Owner

Diffstat muito grande/complexo (ou divergido de um ponto antigo do tip, misturando valor real com drift massivo) para validação segura no fluxo de lote atual — mesmo padrão já aplicado a #11390/#11461/#11392/#11566/#11564/#11562/#11539/#11513. Adiando para sessão dedicada no ciclo v3.8.52.

@diegosouzapw diegosouzapw added the deferred-v3.8.52 Grande demais / suspeito para o lote atual; precisa de sessão dedicada no ciclo v3.8.52 label Aug 26, 2026
@diegosouzapw diegosouzapw changed the title feat: Organizations multi-tenant layer + client-abort server resilience [defer to 3.8.51] [defer] feat: Organizations multi-tenant layer + client-abort server resilience Sep 25, 2026
@diegosouzapw
diegosouzapw changed the base branch from release/v3.8.51 to release/v3.8.52 September 29, 2026 11:29
@diegosouzapw

Copy link
Copy Markdown
Owner

Re-homed to release/v3.8.52: v3.8.51 entered its release freeze, so the branch now belongs to the release captain and development continues on the next cycle. Nothing is wrong with this PR — it just needed a live base. No action needed from you; CI will re-run against the new base.

@diegosouzapw diegosouzapw changed the title [defer] feat: Organizations multi-tenant layer + client-abort server resilience feat: Organizations multi-tenant layer + client-abort server resilience Oct 1, 2026
@diegosouzapw diegosouzapw removed merge-train-deferred PR ejetada do merge-train — triagem separada deferred-v3.8.52 Grande demais / suspeito para o lote atual; precisa de sessão dedicada no ciclo v3.8.52 labels Oct 1, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants