Files
accounted/lib/company/active-company.ts
T
a717f03898 feat(mcp-oauth): let an account with no company connect and sign up from the OAuth popup (#1814 PR 1) (#1855)
* feat(mcp-oauth): let an account with no company connect and sign up from the OAuth popup

Identity unlock for agent-first onboarding (#1814, shape B+). A person
with no Accounted account can now connect from an MCP client, create the
account inside the Connect popup and finish the OAuth dance.

- authorize/token no longer require a company: consent renders a
  companyless variant and the key is minted with company_id NULL.
- validateApiKey returns companyId string|null and binds an unbound key
  to the user's first company on the first validation after it exists.
- MCP server: company-dependent tools and data resources answer with a
  structured NO_COMPANY_YET error; the company-independent tools still
  run; telemetry skips when there is no company scope.
- /api/events fails closed instead of throwing for an unbound key.
- authorize forces TOTP enrollment (not just verification) for password
  accounts with no factor, since the middleware skips enrollment for
  zero-company users; BankID-linked accounts stay exempt.
- /login forwards next to /register; register, GoogleAuthButton and
  /auth/callback carry it back to the consent page (callback honours
  only /api/mcp-oauth/authorize, via safeReturnTo); /mfa/enroll
  hard-navigates to /api/* destinations like /mfa/verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* refactor(company): move getActiveCompanyId out of the next/headers module

lib/auth/api-keys.ts needs the resolver for unbound-key binding, but
lib/company/context.ts imports next/headers for the legacy company cookie
and Turbopack refuses that import on some of api-keys' import paths (the
preview build failed). The resolver and CompanyContextError now live in
lib/company/active-company.ts; context.ts re-exports them so every caller
and test mock is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(mcp-oauth): fail closed on a failed assurance lookup; enroll Back aborts instead of looping

Review findings on #1855: requireAal2 let consent through at AAL1 when
getAuthenticatorAssuranceLevel() returned nothing and a verified factor
existed. Only a positive AAL2 answer passes now; a failed lookup and the
inconsistent verified-factor-at-AAL1 case both step up to /mfa/verify.
Back on /mfa/enroll with the consent page as returnTo went straight back
into the redirect loop; it now aborts to the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:25:42 +02:00

167 lines
6.4 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Active-company resolution with no Next.js request-scope dependency.
*
* Split out of lib/company/context.ts (which imports `next/headers` for the
* legacy company cookie) so that modules on the API-key path, notably
* lib/auth/api-keys.ts, can resolve a user's company without dragging
* `next/headers` into every bundle that validates a key. context.ts
* re-exports everything here; import from there unless the cookie import is
* the problem.
*/
/**
* Thrown by setActiveCompany so callers can tell a permissions problem
* ('not_member') apart from a failed/unverified database write
* ('persist_failed'), and by getActiveCompanyId when a resolution query
* fails ('resolution_failed': the active company is unknown right now,
* which is NOT the same as the user having no companies).
*/
export class CompanyContextError extends Error {
constructor(
message: string,
readonly code: 'not_member' | 'persist_failed' | 'resolution_failed'
) {
super(message)
this.name = 'CompanyContextError'
}
}
/**
* Get the active company ID for the authenticated user.
*
* Resolution order: user_preferences → first non-archived membership.
*
* `user_preferences.active_company_id` is the authoritative source. The
* cookie `gnubok-company-id` is written as a hint for backwards-compat but
* is no longer READ as a source of truth, because Postgres RLS (via
* `current_active_company_id()`) can only read the database, not cookies.
* Having Next.js and RLS both read from `user_preferences` keeps them
* perfectly in sync.
*
* RPC-first: tries `resolve_active_company()` (one round trip, semantically
* identical to the query path and to `current_active_company_id()`), falling
* back to the original query path when the function is not deployed
* (PGRST202), the caller lacks EXECUTE (42501: service-role clients), or the
* RPC returns zero rows (NULL auth.uid(), also service-role clients).
*
* Returns null only when the user positively has no non-archived companies.
* Throws CompanyContextError('resolution_failed') when a query fails: a
* transient failure must never read as "no companies", because callers
* redirect that state to the onboarding wizard (issue #1053).
*/
export async function getActiveCompanyId(
supabase: SupabaseClient,
userId: string
): Promise<string | null> {
const { data, error } = await supabase.rpc('resolve_active_company')
if (error) {
// PGRST202: function not in the schema cache (self-hosted instance not
// migrated yet, or a deploy racing the branch merge).
// 42501: EXECUTE is granted to `authenticated` only, so a service-role
// client is refused. These fallbacks are LOAD-BEARING, not defensive:
// app/api/mcp-oauth/token/route.ts, app/api/events/route.ts (API-key
// branch) and lib/auth/api-keys.ts call this with
// createServiceClientNoCookies(), and must silently resolve via the query
// path or the OAuth token flow breaks.
if (error.code === 'PGRST202' || error.code === '42501') {
return getActiveCompanyIdViaQueries(supabase, userId)
}
throw new CompanyContextError(
`Active company resolution failed: ${error.message}`,
'resolution_failed'
)
}
const row = (Array.isArray(data) ? data[0] : data) as
| { company_id: string | null; locale: string | null; used_fallback: boolean }
| undefined
| null
if (!row) {
// Zero rows = NULL auth.uid() inside the RPC, i.e. a service-role client
// (same call sites as the 42501 branch above). The query path filters by
// the explicit userId param and still resolves correctly.
return getActiveCompanyIdViaQueries(supabase, userId)
}
return row.company_id ?? null
}
/**
* Query-path resolution: the pre-RPC implementation, kept verbatim as the
* fallback for getActiveCompanyId (see the fallback conditions there).
*/
async function getActiveCompanyIdViaQueries(
supabase: SupabaseClient,
userId: string
): Promise<string | null> {
// user_preferences (authoritative) + first membership, fetched in parallel:
// the fallback query result doubles as validation when the preferred
// company happens to be the first membership, which is the common
// single-company case. Most requests pay one round trip instead of two
// sequential ones. This runs on every withRouteContext API request and
// every dashboard layout render, so the sequential version was pure
// wall-clock cost. Mirrors resolveCompanyForMiddleware, minus the
// write-back (read paths shouldn't write).
const [prefsRes, firstRes] = await Promise.all([
supabase
.from('user_preferences')
.select('active_company_id')
.eq('user_id', userId)
.maybeSingle(),
supabase
.from('company_members')
.select('company_id, companies!inner(archived_at)')
.eq('user_id', userId)
.is('companies.archived_at', null)
.order('created_at', { ascending: true })
.limit(1)
.maybeSingle(),
])
const resolutionError = prefsRes.error ?? firstRes.error
if (resolutionError) {
throw new CompanyContextError(
`Active company resolution failed: ${resolutionError.message}`,
'resolution_failed'
)
}
const prefs = prefsRes.data
const firstCompany = firstRes.data
if (prefs?.active_company_id) {
if (firstCompany && prefs.active_company_id === firstCompany.company_id) {
return firstCompany.company_id
}
// Preference points at a different company than the first membership:
// validate it still resolves to a non-archived company the user is a
// member of before trusting it.
const { data: membership, error: membershipError } = await supabase
.from('company_members')
.select('company_id, companies!inner(archived_at)')
.eq('company_id', prefs.active_company_id)
.eq('user_id', userId)
.is('companies.archived_at', null)
.maybeSingle()
// Falling back to the first membership on a FAILED validation would
// silently switch a multi-company user's active company: fail loudly.
if (membershipError) {
throw new CompanyContextError(
`Active company validation failed: ${membershipError.message}`,
'resolution_failed'
)
}
if (membership) return membership.company_id
}
// Fallback: first non-archived membership by created_at (already fetched)
return firstCompany?.company_id ?? null
}