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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
2a33291c18
commit
a717f03898
@@ -4,6 +4,14 @@ vi.mock('@supabase/supabase-js', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
const contextMocks = vi.hoisted(() => ({
|
||||
getActiveCompanyId: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/active-company', () => ({
|
||||
getActiveCompanyId: (...args: unknown[]) => contextMocks.getActiveCompanyId(...args),
|
||||
}))
|
||||
|
||||
import {
|
||||
generateApiKey,
|
||||
hashApiKey,
|
||||
@@ -339,4 +347,75 @@ describe('validateApiKey', () => {
|
||||
mode: 'test',
|
||||
})
|
||||
})
|
||||
|
||||
describe('unbound keys (minted before the first company existed, issue #1814)', () => {
|
||||
function setupUnboundKeyClient(rpcRow: Record<string, unknown>) {
|
||||
const chain = {
|
||||
update: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
is: vi.fn().mockResolvedValue({ data: null, error: null }),
|
||||
}
|
||||
chain.update.mockReturnValue(chain)
|
||||
chain.eq.mockReturnValue(chain)
|
||||
const from = vi.fn().mockReturnValue(chain)
|
||||
const rpc = vi.fn().mockResolvedValue({ data: [rpcRow], error: null })
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockCreateClient.mockReturnValue({ rpc, from } as any)
|
||||
return { from, chain }
|
||||
}
|
||||
|
||||
const unboundRow = {
|
||||
user_id: 'user-123',
|
||||
company_id: null,
|
||||
api_key_id: 'ak_1',
|
||||
api_key_name: 'MCP-klient (OAuth)',
|
||||
scopes: ['transactions:read'],
|
||||
rate_limited: false,
|
||||
mode: 'live',
|
||||
}
|
||||
|
||||
it('binds the key to the user\'s company once one exists and heals the row', async () => {
|
||||
const { from, chain } = setupUnboundKeyClient(unboundRow)
|
||||
contextMocks.getActiveCompanyId.mockResolvedValue('company-789')
|
||||
|
||||
const result = await validateApiKey('gnubok_sk_test-key-value')
|
||||
|
||||
expect('companyId' in result && result.companyId).toBe('company-789')
|
||||
expect(from).toHaveBeenCalledWith('api_keys')
|
||||
expect(chain.update).toHaveBeenCalledWith({ company_id: 'company-789' })
|
||||
expect(chain.eq).toHaveBeenCalledWith('id', 'ak_1')
|
||||
// Only an unbound row is ever rewritten: a concurrent bind must not be clobbered.
|
||||
expect(chain.is).toHaveBeenCalledWith('company_id', null)
|
||||
})
|
||||
|
||||
it('returns companyId null while the user still has no company', async () => {
|
||||
const { from } = setupUnboundKeyClient(unboundRow)
|
||||
contextMocks.getActiveCompanyId.mockResolvedValue(null)
|
||||
|
||||
const result = await validateApiKey('gnubok_sk_test-key-value')
|
||||
|
||||
expect('companyId' in result && result.companyId).toBeNull()
|
||||
expect(from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats a failed company resolution as still unbound rather than failing the key', async () => {
|
||||
setupUnboundKeyClient(unboundRow)
|
||||
contextMocks.getActiveCompanyId.mockRejectedValue(new Error('db blip'))
|
||||
|
||||
const result = await validateApiKey('gnubok_sk_test-key-value')
|
||||
|
||||
expect('companyId' in result && result.companyId).toBeNull()
|
||||
expect('userId' in result && result.userId).toBe('user-123')
|
||||
})
|
||||
|
||||
it('skips the heal when the RPC predates api_key_id in its return shape', async () => {
|
||||
const { from } = setupUnboundKeyClient({ ...unboundRow, api_key_id: undefined })
|
||||
contextMocks.getActiveCompanyId.mockResolvedValue('company-789')
|
||||
|
||||
const result = await validateApiKey('gnubok_sk_test-key-value')
|
||||
|
||||
expect('companyId' in result && result.companyId).toBe('company-789')
|
||||
expect(from).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+47
-2
@@ -1,5 +1,10 @@
|
||||
import crypto from 'crypto'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createServiceRoleClient } from '@/lib/supabase/service-client'
|
||||
// Not lib/company/context: that module imports next/headers for the legacy
|
||||
// company cookie, and this file is reachable from bundles where that import
|
||||
// is a build error.
|
||||
import { getActiveCompanyId } from '@/lib/company/active-company'
|
||||
|
||||
const KEY_PREFIX = 'gnubok_sk_'
|
||||
const REFRESH_TOKEN_PREFIX = 'gnubok_rt_'
|
||||
@@ -473,7 +478,12 @@ export async function validateApiKey(
|
||||
): Promise<
|
||||
| {
|
||||
userId: string
|
||||
companyId: string
|
||||
/**
|
||||
* The key's default company. null only while the key's user has no
|
||||
* company at all (minted from the OAuth popup before onboarding, issue
|
||||
* #1814): the first validation after a company exists binds the key.
|
||||
*/
|
||||
companyId: string | null
|
||||
apiKeyId?: string
|
||||
apiKeyName?: string
|
||||
scopes: ApiKeyScope[]
|
||||
@@ -509,9 +519,12 @@ export async function validateApiKey(
|
||||
return { error: 'Rate limit exceeded', status: 429 }
|
||||
}
|
||||
|
||||
const companyId: string | null =
|
||||
row.company_id ?? (await bindUnboundKey(supabase, row.user_id, row.api_key_id))
|
||||
|
||||
return {
|
||||
userId: row.user_id,
|
||||
companyId: row.company_id,
|
||||
companyId,
|
||||
apiKeyId: row.api_key_id,
|
||||
apiKeyName: row.api_key_name,
|
||||
scopes: validateScopes(row.scopes) ?? DEFAULT_SCOPES,
|
||||
@@ -522,6 +535,38 @@ export async function validateApiKey(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Late binding for keys minted before the user's first company existed.
|
||||
*
|
||||
* The OAuth token endpoint stores company_id NULL for such keys. Company
|
||||
* creation happens in the web app (a Server Action) which knows nothing about
|
||||
* the user's keys, so the binding is healed here, on the first validation after
|
||||
* a company exists: one place, regardless of how the company was created.
|
||||
* Returns null while the user still has no company. The UPDATE is best-effort:
|
||||
* a failed write only means the next call resolves again.
|
||||
*/
|
||||
async function bindUnboundKey(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
apiKeyId: string | undefined
|
||||
): Promise<string | null> {
|
||||
let companyId: string | null
|
||||
try {
|
||||
companyId = await getActiveCompanyId(supabase, userId)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!companyId) return null
|
||||
if (apiKeyId) {
|
||||
await supabase
|
||||
.from('api_keys')
|
||||
.update({ company_id: companyId })
|
||||
.eq('id', apiKeyId)
|
||||
.is('company_id', null)
|
||||
}
|
||||
return companyId
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given scope is allowed by the key's scopes.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
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
|
||||
}
|
||||
+6
-153
@@ -2,162 +2,15 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { cookies } from 'next/headers'
|
||||
import type { EntityType } from '@/types'
|
||||
import { CompanyContextError, getActiveCompanyId } from '@/lib/company/active-company'
|
||||
|
||||
// The resolver and its error class live in active-company.ts (no
|
||||
// `next/headers` there) so the API-key path can use them; re-exported here so
|
||||
// every existing import site and test mock keeps working.
|
||||
export { CompanyContextError, getActiveCompanyId }
|
||||
|
||||
const COMPANY_COOKIE = 'gnubok-company-id'
|
||||
|
||||
/**
|
||||
* 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 and app/api/events/route.ts (API-key
|
||||
// branch) call requireCompanyId 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a company's effective entity type.
|
||||
*
|
||||
|
||||
@@ -2858,6 +2858,19 @@ const SALARY: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Företaget kunde inte hittas.',
|
||||
message_en: 'Company not found.',
|
||||
},
|
||||
// An API key minted for an account that has not created its first company
|
||||
// yet (signup from the MCP OAuth popup, issue #1814). Not a lookup miss:
|
||||
// there is nothing to look up until the company exists.
|
||||
NO_COMPANY_YET: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Kontot har inget företag ännu. Skapa företaget i appen och försök igen.',
|
||||
message_en: 'This account has no company yet. Create the company in the web app, then retry; the connection picks it up automatically.',
|
||||
remediation: {
|
||||
description:
|
||||
'Ask the user to finish company setup in the Accounted web app (/onboarding). No re-authentication is needed afterwards: the same connection binds to the new company on its next call.',
|
||||
tool: 'gnubok_list_companies',
|
||||
},
|
||||
},
|
||||
// Phase 5 PR-1 carry-over: distinct error code for the salary-run DELETE
|
||||
// FK-null guard so an operator seeing this in logs knows a journal entry
|
||||
// is at risk, not just a status race.
|
||||
|
||||
Reference in New Issue
Block a user