Fix/supp ag fb (#1023)

* fix: prevent credit notes from entering payment flow

* fix: persist and display customer personal numbers

* feat: configure automatic invoice reminder days

* fix: issue credit notes through send flow

* chore: add repository agent guidance

* feat(mcp): route tools across user companies

* fix(articles): delete unused register entries

* feat(invoices): improve issued invoice actions

* feat(supplier-invoices): retain uploaded source documents

* docs: record implementation decisions

* feat: enhance customer personal number handling and validation

- Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers.
- Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema.
- Implemented masking and encryption for personal numbers to enhance data protection.
- Introduced new utility functions for masking and encrypting personal numbers.
- Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries.
- Enhanced error handling and logging for credit note issuance and invoice processing.
- Updated tests to cover new credit note creation guards and personal number handling.

* test: enhance list companies test with supabase query mocks
This commit is contained in:
Mattsson
2026-07-15 15:53:15 +02:00
committed by GitHub
parent a558c75678
commit 072aedeaf9
116 changed files with 5708 additions and 669 deletions
+1 -1
View File
@@ -14,6 +14,6 @@
"dataPattern": "core",
"readsCoreTables": ["invoices", "customers", "company_settings"],
"description": "Skicka fakturor och påminnelser via e-post",
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser (15/30/45 dagar), och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän."
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän."
}
}
@@ -34,6 +34,24 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
},
},
)
const membershipChain: unknown = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({
data: {
company_id: '11111111-1111-4111-8111-111111111111',
role: 'owner',
},
error: null,
})
}
return () => membershipChain
},
}
)
return {
...actual,
extractBearerToken: vi.fn().mockReturnValue('test-token'),
@@ -48,7 +66,10 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
apiKeyId: 'key-1',
apiKeyName: 'Test Key',
}),
createServiceClientNoCookies: vi.fn(() => ({ from: () => chain, rpc: () => chain })),
createServiceClientNoCookies: vi.fn(() => ({
from: (table: string) => (table === 'company_members' ? membershipChain : chain),
rpc: () => chain,
})),
}
})
@@ -0,0 +1,183 @@
import { describe, expect, it, vi } from 'vitest'
import {
addCompanyToNextHint,
addCompanyToTopLevelNext,
assertMcpCompanyWriteAccess,
extractRequestedCompany,
isCompanyDependentTool,
projectToolInputSchema,
resolveMcpCompanyContext,
} from '../company-routing'
const DEFAULT_COMPANY_ID = '11111111-1111-4111-8111-111111111111'
const OTHER_COMPANY_ID = '22222222-2222-4222-8222-222222222222'
function membershipClient(result: { data: unknown; error: unknown }) {
const chain: Record<string, ReturnType<typeof vi.fn>> = {
select: vi.fn(() => chain),
eq: vi.fn(() => chain),
is: vi.fn(() => chain),
maybeSingle: vi.fn().mockResolvedValue(result),
}
return {
client: { from: vi.fn(() => chain) },
chain,
}
}
describe('MCP company routing', () => {
it('projects company_id onto company-dependent tool schemas without mutating the source', () => {
const inputSchema = {
type: 'object',
additionalProperties: false,
properties: { invoice_id: { type: 'string' } },
required: ['invoice_id'],
}
const projected = projectToolInputSchema({ name: 'gnubok_send_invoice', inputSchema })
expect(projected).not.toBe(inputSchema)
expect(projected.properties).toEqual({
invoice_id: { type: 'string' },
company_id: expect.objectContaining({ type: 'string', format: 'uuid' }),
})
expect(inputSchema.properties).not.toHaveProperty('company_id')
expect(projected.additionalProperties).toBe(false)
})
it.each(['gnubok_search_tools', 'gnubok_load_skill', 'gnubok_list_companies'])(
'keeps the company-independent schema unchanged for %s',
(name) => {
const inputSchema = {
type: 'object',
additionalProperties: false,
properties: {},
}
expect(isCompanyDependentTool(name)).toBe(false)
expect(projectToolInputSchema({ name, inputSchema })).toBe(inputSchema)
}
)
it('extracts and strips a valid company_id before tool execution', () => {
expect(
extractRequestedCompany({ company_id: OTHER_COMPANY_ID, invoice_id: 'invoice-1' })
).toEqual({
requestedCompanyId: OTHER_COMPANY_ID,
toolArgs: { invoice_id: 'invoice-1' },
})
})
it('rejects a malformed company_id', () => {
expect(() => extractRequestedCompany({ company_id: 'not-a-uuid' })).toThrow(
expect.objectContaining({ code: 'VALIDATION_ERROR' })
)
})
it('checks membership and resolves the requested company role', async () => {
const { client, chain } = membershipClient({
data: { company_id: OTHER_COMPANY_ID, role: 'admin' },
error: null,
})
await expect(
resolveMcpCompanyContext({
supabase: client as never,
userId: 'user-1',
defaultCompanyId: DEFAULT_COMPANY_ID,
requestedCompanyId: OTHER_COMPANY_ID,
})
).resolves.toEqual({
companyId: OTHER_COMPANY_ID,
role: 'admin',
isDefault: false,
})
expect(chain.eq).toHaveBeenCalledWith('user_id', 'user-1')
expect(chain.eq).toHaveBeenCalledWith('company_id', OTHER_COMPANY_ID)
expect(chain.is).toHaveBeenCalledWith('companies.archived_at', null)
})
it('checks the API key default company when company_id is omitted', async () => {
const { client, chain } = membershipClient({
data: { company_id: DEFAULT_COMPANY_ID, role: 'owner' },
error: null,
})
await expect(
resolveMcpCompanyContext({
supabase: client as never,
userId: 'user-1',
defaultCompanyId: DEFAULT_COMPANY_ID,
})
).resolves.toEqual({
companyId: DEFAULT_COMPANY_ID,
role: 'owner',
isDefault: true,
})
expect(chain.eq).toHaveBeenCalledWith('company_id', DEFAULT_COMPANY_ID)
})
it('rejects companies without a current non-archived membership', async () => {
const { client } = membershipClient({ data: null, error: null })
await expect(
resolveMcpCompanyContext({
supabase: client as never,
userId: 'user-1',
defaultCompanyId: DEFAULT_COMPANY_ID,
requestedCompanyId: OTHER_COMPANY_ID,
})
).rejects.toMatchObject({ code: 'NOT_FOUND' })
})
it('fails closed when the membership lookup fails', async () => {
const { client } = membershipClient({
data: null,
error: { message: 'database unavailable' },
})
await expect(
resolveMcpCompanyContext({
supabase: client as never,
userId: 'user-1',
defaultCompanyId: DEFAULT_COMPANY_ID,
})
).rejects.toMatchObject({ code: 'INTERNAL_ERROR' })
})
it('allows viewer reads but rejects viewer writes, approvals, and management', () => {
const context = { companyId: OTHER_COMPANY_ID, role: 'viewer' as const, isDefault: false }
expect(() => assertMcpCompanyWriteAccess(context, 'reports:read')).not.toThrow()
expect(() => assertMcpCompanyWriteAccess(context, undefined)).not.toThrow()
expect(() => assertMcpCompanyWriteAccess(context, 'invoices:write')).toThrow(
expect.objectContaining({ code: 'FORBIDDEN' })
)
expect(() => assertMcpCompanyWriteAccess(context, 'pending_operations:approve')).toThrow(
expect.objectContaining({ code: 'FORBIDDEN' })
)
expect(() => assertMcpCompanyWriteAccess(context, 'webhooks:manage')).toThrow(
expect.objectContaining({ code: 'FORBIDDEN' })
)
})
it('keeps company context in follow-up tool hints', () => {
const next = {
tool: 'gnubok_approve_pending_operation',
description: 'Approve the operation',
args: { operation_id: 'operation-1' },
}
expect(addCompanyToNextHint(next, OTHER_COMPANY_ID)).toEqual({
...next,
args: { operation_id: 'operation-1', company_id: OTHER_COMPANY_ID },
})
expect(addCompanyToTopLevelNext({ data: {}, next }, OTHER_COMPANY_ID)).toEqual({
data: {},
next: {
...next,
args: { operation_id: 'operation-1', company_id: OTHER_COMPANY_ID },
},
})
})
})
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
vi.mock('@/lib/company/context', () => ({
getUserCompanies: vi.fn(),
}))
import { getUserCompanies } from '@/lib/company/context'
import { tools } from '../server'
const DEFAULT_COMPANY_ID = '11111111-1111-4111-8111-111111111111'
const OTHER_COMPANY_ID = '22222222-2222-4222-8222-222222222222'
const ARCHIVED_COMPANY_ID = '33333333-3333-4333-8333-333333333333'
const listCompaniesTool = tools.find((tool) => tool.name === 'gnubok_list_companies')!
describe('gnubok_list_companies', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('is a read-only companies:read discovery tool', () => {
expect(listCompaniesTool).toBeDefined()
expect(TOOL_SCOPE_MAP.gnubok_list_companies).toBe('companies:read')
expect(listCompaniesTool.annotations).toMatchObject({
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
})
})
it('lists every non-archived membership with role and marks the default', async () => {
vi.mocked(getUserCompanies).mockResolvedValue([
{
company_id: DEFAULT_COMPANY_ID,
role: 'owner',
joined_at: '2026-01-01',
companies: {
id: DEFAULT_COMPANY_ID,
name: 'Legal Default AB',
org_number: '559000-0001',
entity_type: 'AB',
archived_at: null,
created_at: '2026-01-01',
},
},
{
company_id: OTHER_COMPANY_ID,
role: 'viewer',
joined_at: '2026-02-01',
companies: {
id: OTHER_COMPANY_ID,
name: 'Other Legal Name',
org_number: null,
entity_type: 'EF',
archived_at: null,
created_at: '2026-02-01',
},
},
{
company_id: ARCHIVED_COMPANY_ID,
role: 'admin',
joined_at: '2026-03-01',
companies: {
id: ARCHIVED_COMPANY_ID,
name: 'Archived AB',
org_number: '559000-0003',
entity_type: 'AB',
archived_at: '2026-06-01',
created_at: '2026-03-01',
},
},
] as never)
const rangeMock = vi.fn().mockResolvedValue({
data: [{ company_id: DEFAULT_COMPANY_ID, company_name: 'Configured Default AB' }],
error: null,
})
const orderMock = vi.fn(() => ({ range: rangeMock }))
const inMock = vi.fn(() => ({ order: orderMock }))
const supabase = {
from: vi.fn(() => ({
select: vi.fn(() => ({ in: inMock })),
})),
}
const result = (await listCompaniesTool.execute(
{},
DEFAULT_COMPANY_ID,
'user-1',
supabase as never,
{ type: 'api_key' }
)) as Record<string, unknown>
expect(getUserCompanies).toHaveBeenCalledWith(supabase, 'user-1')
expect(inMock).toHaveBeenCalledWith('company_id', [DEFAULT_COMPANY_ID, OTHER_COMPANY_ID])
expect(orderMock).toHaveBeenCalledWith('company_id', { ascending: true })
expect(rangeMock).toHaveBeenCalledWith(0, 999)
expect(result).toEqual({
companies: [
{
company_id: DEFAULT_COMPANY_ID,
name: 'Configured Default AB',
org_number: '559000-0001',
entity_type: 'AB',
role: 'owner',
is_default: true,
},
{
company_id: OTHER_COMPANY_ID,
name: 'Other Legal Name',
org_number: null,
entity_type: 'EF',
role: 'viewer',
is_default: false,
},
],
count: 2,
default_company_id: DEFAULT_COMPANY_ID,
})
})
})
@@ -0,0 +1,165 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { eventBus } from '@/lib/events/bus'
const DEFAULT_COMPANY_ID = '11111111-1111-4111-8111-111111111111'
const OTHER_COMPANY_ID = '22222222-2222-4222-8222-222222222222'
const mocks = vi.hoisted(() => ({
membership: {
data: {
company_id: '22222222-2222-4222-8222-222222222222',
role: 'owner',
} as Record<string, unknown> | null,
error: null as { message: string } | null,
},
companyIds: [] as string[],
hasCapability: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
return {
...actual,
extractBearerToken: vi.fn().mockReturnValue('test-token'),
validateApiKey: vi.fn().mockResolvedValue({
userId: 'user-1',
companyId: '11111111-1111-4111-8111-111111111111',
scopes: ['companies:read', 'invoices:write', 'reports:read'],
apiKeyId: 'key-1',
apiKeyName: 'Test Key',
}),
createServiceClientNoCookies: vi.fn(() => ({
from: vi.fn((table: string) => {
if (table !== 'company_members') throw new Error(`Unexpected table: ${table}`)
const chain: Record<string, ReturnType<typeof vi.fn>> = {
select: vi.fn(() => chain),
eq: vi.fn((column: string, value: string) => {
if (column === 'company_id') mocks.companyIds.push(value)
return chain
}),
is: vi.fn(() => chain),
maybeSingle: vi.fn(async () => mocks.membership),
}
return chain
}),
})),
}
})
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
return { ...actual, hasCapability: mocks.hasCapability }
})
import { handleMcpRequest } from '../server'
function toolCall(args: Record<string, unknown>): Request {
return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'gnubok_send_invoice', arguments: args },
}),
})
}
async function parseToolResult(response: Response) {
const json = await response.json()
const result = json.result as { isError?: boolean; content: Array<{ text: string }> }
return {
isError: result.isError === true,
payload: JSON.parse(result.content[0].text) as Record<string, unknown>,
}
}
describe('MCP multi-company dispatch', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mocks.companyIds.length = 0
mocks.membership = {
data: { company_id: OTHER_COMPANY_ID, role: 'owner' },
error: null,
}
mocks.hasCapability.mockResolvedValue(false)
})
it('routes a tool call to an accessible requested company', async () => {
const result = await parseToolResult(
await handleMcpRequest(
toolCall({ invoice_id: 'invoice-1', company_id: OTHER_COMPANY_ID })
)
)
expect(result.isError).toBe(true)
expect((result.payload.error as Record<string, unknown>).capability_blocked).toBe(true)
expect(mocks.companyIds).toEqual([OTHER_COMPANY_ID])
expect(mocks.hasCapability).toHaveBeenCalledWith(
expect.anything(),
OTHER_COMPANY_ID,
'email_send'
)
})
it('revalidates and uses the API key default company when company_id is omitted', async () => {
mocks.membership.data = { company_id: DEFAULT_COMPANY_ID, role: 'admin' }
await handleMcpRequest(toolCall({ invoice_id: 'invoice-1' }))
expect(mocks.companyIds).toEqual([DEFAULT_COMPANY_ID])
expect(mocks.hasCapability).toHaveBeenCalledWith(
expect.anything(),
DEFAULT_COMPANY_ID,
'email_send'
)
})
it('rejects a company the user does not belong to before capability or execution', async () => {
mocks.membership.data = null
const result = await parseToolResult(
await handleMcpRequest(
toolCall({ invoice_id: 'invoice-1', company_id: OTHER_COMPANY_ID })
)
)
expect(result.isError).toBe(true)
expect((result.payload.error as Record<string, unknown>).code).toBe('NOT_FOUND')
expect(mocks.hasCapability).not.toHaveBeenCalled()
})
it('rejects writes for a viewer in the selected company', async () => {
mocks.membership.data = { company_id: OTHER_COMPANY_ID, role: 'viewer' }
const result = await parseToolResult(
await handleMcpRequest(
toolCall({ invoice_id: 'invoice-1', company_id: OTHER_COMPANY_ID })
)
)
expect(result.isError).toBe(true)
expect((result.payload.error as Record<string, unknown>).code).toBe('FORBIDDEN')
expect(mocks.hasCapability).not.toHaveBeenCalled()
})
it('rejects malformed company_id before querying membership', async () => {
const result = await parseToolResult(
await handleMcpRequest(
toolCall({ invoice_id: 'invoice-1', company_id: 'not-a-uuid' })
)
)
expect(result.isError).toBe(true)
expect((result.payload.error as Record<string, unknown>).code).toBe('VALIDATION_ERROR')
expect(mocks.companyIds).toEqual([])
expect(mocks.hasCapability).not.toHaveBeenCalled()
})
})
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { tools, deriveToolMeta } from '../server'
import { projectToolInputSchema } from '../company-routing'
describe('tools/list payload size guard', () => {
it('keeps the projected tools/list payload under the context-budget ceiling', () => {
@@ -12,7 +13,7 @@ describe('tools/list payload size guard', () => {
name: t.name,
...(t.title ? { title: t.title } : {}),
description: t.description,
inputSchema: t.inputSchema,
inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
@@ -20,7 +21,7 @@ describe('tools/list payload size guard', () => {
})
const payload = JSON.stringify({ tools: projection })
const approxTokens = Math.round(payload.length / 4)
// Ceiling progression: 20K → 25K → 30K → 31K → 31.5K → 32K → 36K.
// Ceiling progression: 20K to 25K to 30K to 31K to 31.5K to 32K to 36K.
// * 20K → 25K when item 8 of the agent-native API plan landed
// (additionalProperties: false on all inputSchemas + period_status in the
// staged operation envelope).
@@ -110,9 +111,14 @@ describe('tools/list payload size guard', () => {
// gnubok_get_vacation_balance (ledger read) + gnubok_close_vacation_year
// (staged HIGH semesterårsavslut with STAGED_OPERATION_SCHEMA + _meta).
// Fortnox gap category E closed; both schemas already minimal.
// * 51K to 54K for stateless multi-company MCP routing. Every
// company-dependent tool must expose the optional company_id input so
// the client can target another authorized company without shared
// mutable connection state. The repeated property is intentionally
// minimal; gnubok_list_companies and initialize instructions explain it.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(51_000)
expect(approxTokens).toBeLessThan(54_000)
})
})
@@ -24,6 +24,7 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
extractBearerToken: vi.fn().mockReturnValue('test-token'),
validateApiKey: vi.fn().mockResolvedValue({
userId: 'user-1',
companyId: '11111111-1111-4111-8111-111111111111',
scopes: ['transactions:read', 'transactions:write', 'customers:read', 'customers:write', 'invoices:read', 'invoices:write', 'suppliers:read', 'reports:read'],
}),
createServiceClientNoCookies: vi.fn(),
@@ -179,7 +180,30 @@ describe('MCP Receipt Matcher', () => {
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
vi.mocked(createServiceClientNoCookies).mockReturnValue(supabase as never)
const membershipChain: unknown = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({
data: {
company_id: '11111111-1111-4111-8111-111111111111',
role: 'owner',
},
error: null,
})
}
return () => membershipChain
},
}
)
vi.mocked(createServiceClientNoCookies).mockReturnValue({
...supabase,
from: vi.fn((table: string) =>
table === 'company_members' ? membershipChain : supabase.from(table)
),
} as never)
})
// ── Protocol: initialize includes resources capability ──
@@ -55,6 +55,19 @@ describe('gnubok_search_tools', () => {
const tool = result.tools[0]
expect(tool).toHaveProperty('inputSchema')
expect(tool).toHaveProperty('outputSchema')
expect(
(tool.inputSchema as { properties: Record<string, unknown> }).properties
).toHaveProperty('company_id')
})
it('does not add company_id to company-independent discovery tools', async () => {
const result = await call({ detail: 'full', query: 'search tools', limit: 5 })
const tool = result.tools.find((candidate) => candidate.name === 'gnubok_search_tools')
expect(tool).toBeDefined()
expect(
(tool!.inputSchema as { properties: Record<string, unknown> }).properties
).not.toHaveProperty('company_id')
})
it('filters by query keyword', async () => {
@@ -11,6 +11,8 @@
*/
import { describe, it, expect } from 'vitest'
import { tools } from '../server'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { isTenantWriteScope } from '../company-routing'
describe('MCP tool inputSchema strictness', () => {
it('every tool inputSchema has additionalProperties: false at the top level', () => {
@@ -22,4 +24,21 @@ describe('MCP tool inputSchema strictness', () => {
.map((t) => t.name)
expect(missing).toEqual([])
})
it('every tenant write tool has a scope that the central role guard can classify', () => {
const allowedNonTenantWrites = new Set([
'gnubok_audit_package',
'gnubok_feedback',
])
const missing = tools
.filter(
(tool) =>
tool.annotations.readOnlyHint !== true &&
!isTenantWriteScope(TOOL_SCOPE_MAP[tool.name]) &&
!allowedNonTenantWrites.has(tool.name)
)
.map((tool) => tool.name)
expect(missing).toEqual([])
})
})
@@ -22,7 +22,7 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
extractBearerToken: vi.fn().mockReturnValue('test-token'),
validateApiKey: vi.fn().mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
companyId: '11111111-1111-4111-8111-111111111111',
// Only reports:read: enough to call gnubok_get_trial_balance, NOT enough
// to call gnubok_create_invoice (invoices:write). Drives the scope-denied test.
scopes: ['reports:read'],
@@ -35,6 +35,24 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
// filter has data to work against.
createServiceClientNoCookies: vi.fn(() => ({
from: vi.fn((table: string) => {
if (table === 'company_members') {
return {
select: vi.fn(() => {
const chain: Record<string, ReturnType<typeof vi.fn>> = {
eq: vi.fn(() => chain),
is: vi.fn(() => chain),
maybeSingle: vi.fn().mockResolvedValue({
data: {
company_id: '11111111-1111-4111-8111-111111111111',
role: 'owner',
},
error: null,
}),
}
return chain
}),
}
}
if (table === 'company_settings') {
return {
select: vi.fn(() => ({
@@ -191,7 +209,7 @@ describe('mcp.tool_called telemetry', () => {
expect(event.actorId).toBe('key-1')
expect(event.actorLabel).toBe('Test Key')
expect(event.userId).toBe('user-1')
expect(event.companyId).toBe('company-1')
expect(event.companyId).toBe('11111111-1111-4111-8111-111111111111')
expect(event.requestId).toBe(1)
// Real wall-clock: non-negative number
expect(typeof event.latencyMs).toBe('number')
@@ -393,7 +411,7 @@ describe('mcp.tools_list_called telemetry', () => {
expect(event.toolCount).toBeLessThan(100)
expect(event.actorType).toBe('api_key')
expect(event.userId).toBe('user-1')
expect(event.companyId).toBe('company-1')
expect(event.companyId).toBe('11111111-1111-4111-8111-111111111111')
expect(typeof event.latencyMs).toBe('number')
expect(event.latencyMs).toBeGreaterThanOrEqual(0)
})
@@ -496,7 +514,7 @@ describe('mcp.skill_loaded telemetry', () => {
expect(event.actorType).toBe('api_key')
expect(event.actorId).toBe('key-1')
expect(event.userId).toBe('user-1')
expect(event.companyId).toBe('company-1')
expect(event.companyId).toBe('11111111-1111-4111-8111-111111111111')
// The pre-existing workflow-funnel event still fires for workflow tier.
const wf = await workflowStartedPromise
@@ -29,6 +29,24 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
},
},
)
const membershipChain: unknown = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({
data: {
company_id: '11111111-1111-4111-8111-111111111111',
role: 'owner',
},
error: null,
})
}
return () => membershipChain
},
}
)
return {
...actual,
extractBearerToken: vi.fn().mockReturnValue('test-token'),
@@ -42,7 +60,10 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
apiKeyName: 'Test Key',
mode: 'test',
}),
createServiceClientNoCookies: vi.fn(() => ({ from: () => chain, rpc: () => chain })),
createServiceClientNoCookies: vi.fn(() => ({
from: (table: string) => (table === 'company_members' ? membershipChain : chain),
rpc: () => chain,
})),
}
})
@@ -19,7 +19,7 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
extractBearerToken: vi.fn().mockReturnValue('test-token'),
validateApiKey: vi.fn().mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
companyId: '11111111-1111-4111-8111-111111111111',
scopes: ['reports:read'],
}),
// Fully-chainable, awaitable proxy resolving to empty data: satisfies both
@@ -38,7 +38,27 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
},
},
)
return { from: () => makeChain() }
const membershipChain: unknown = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({
data: {
company_id: '11111111-1111-4111-8111-111111111111',
role: 'owner',
},
error: null,
})
}
return () => membershipChain
},
}
)
return {
from: (table: string) => (table === 'company_members' ? membershipChain : makeChain()),
}
}),
}
})
@@ -0,0 +1,146 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ApiKeyScope } from '@/lib/auth/api-keys'
import type { CompanyRole } from '@/types'
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const COMPANY_INDEPENDENT_TOOLS = new Set([
'gnubok_search_tools',
'gnubok_load_skill',
'gnubok_list_companies',
])
export const COMPANY_ID_INPUT_PROPERTY = {
type: 'string',
format: 'uuid',
description: 'Target company ID. Omit for default.',
} as const
export interface McpCompanyContext {
companyId: string
role: CompanyRole
isDefault: boolean
}
interface ToolSchemaSource {
name: string
inputSchema: Record<string, unknown>
}
function codedError(
code: 'VALIDATION_ERROR' | 'NOT_FOUND' | 'FORBIDDEN' | 'INTERNAL_ERROR',
message: string
) {
return Object.assign(new Error(message), { code })
}
function isCompanyRole(value: unknown): value is CompanyRole {
return value === 'owner' || value === 'admin' || value === 'member' || value === 'viewer'
}
export function isCompanyDependentTool(toolName: string): boolean {
return !COMPANY_INDEPENDENT_TOOLS.has(toolName)
}
export function isTenantWriteScope(scope: ApiKeyScope | undefined): boolean {
return (
scope?.endsWith(':write') === true ||
scope?.endsWith(':approve') === true ||
scope?.endsWith(':manage') === true
)
}
export function projectToolInputSchema(tool: ToolSchemaSource): Record<string, unknown> {
if (!isCompanyDependentTool(tool.name)) return tool.inputSchema
const properties =
tool.inputSchema.properties && typeof tool.inputSchema.properties === 'object'
? (tool.inputSchema.properties as Record<string, unknown>)
: {}
return {
...tool.inputSchema,
properties: {
...properties,
company_id: COMPANY_ID_INPUT_PROPERTY,
},
}
}
export function extractRequestedCompany(
rawArgs: Record<string, unknown>
): { requestedCompanyId: string | undefined; toolArgs: Record<string, unknown> } {
const { company_id: rawCompanyId, ...toolArgs } = rawArgs
if (rawCompanyId === undefined) return { requestedCompanyId: undefined, toolArgs }
if (typeof rawCompanyId !== 'string' || !UUID_PATTERN.test(rawCompanyId)) {
throw codedError('VALIDATION_ERROR', 'company_id must be a valid UUID')
}
return { requestedCompanyId: rawCompanyId, toolArgs }
}
export async function resolveMcpCompanyContext(args: {
supabase: SupabaseClient
userId: string
defaultCompanyId: string
requestedCompanyId?: string
}): Promise<McpCompanyContext> {
const companyId = args.requestedCompanyId ?? args.defaultCompanyId
const { data: membership, error } = await args.supabase
.from('company_members')
.select('company_id, role, companies!inner(archived_at)')
.eq('user_id', args.userId)
.eq('company_id', companyId)
.is('companies.archived_at', null)
.maybeSingle()
if (error) {
throw codedError('INTERNAL_ERROR', `Failed to resolve company membership: ${error.message}`)
}
if (!membership) {
throw codedError('NOT_FOUND', 'Company not found')
}
if (!isCompanyRole(membership.role)) {
throw codedError('FORBIDDEN', 'Company membership has an unsupported role')
}
return {
companyId,
role: membership.role,
isDefault: companyId === args.defaultCompanyId,
}
}
export function assertMcpCompanyWriteAccess(
context: McpCompanyContext,
scope: ApiKeyScope | undefined
): void {
if (context.role === 'viewer' && isTenantWriteScope(scope)) {
throw codedError('FORBIDDEN', 'Write permission required for this company')
}
}
export function addCompanyToNextHint(next: unknown, companyId: string): unknown {
if (!next || typeof next !== 'object' || Array.isArray(next)) return next
const hint = next as Record<string, unknown>
if (typeof hint.tool !== 'string' || !isCompanyDependentTool(hint.tool)) return next
const args =
hint.args && typeof hint.args === 'object' && !Array.isArray(hint.args)
? (hint.args as Record<string, unknown>)
: {}
return {
...hint,
args: { ...args, company_id: companyId },
}
}
export function addCompanyToTopLevelNext(result: unknown, companyId: string): unknown {
if (!result || typeof result !== 'object' || Array.isArray(result)) return result
const record = result as Record<string, unknown>
if (!record.next) return result
return {
...record,
next: addCompanyToNextHint(record.next, companyId),
}
}
@@ -12,7 +12,7 @@ import type { McpResource } from './types'
export const companyCurrentResource: McpResource = {
uri: 'Accounted://company/current',
name: 'Active Company',
description: 'Per-company working memory: identity, active fiscal period, lock dates, entity counts, voucher series state, recent activity, approaching Swedish filing deadlines. Read this first when starting work on a company.',
description: 'Working memory for the API key default company: identity, active fiscal period, lock dates, entity counts, voucher series state, recent activity, and filing deadlines. For another company, call gnubok_get_agent_briefing with company_id.',
mimeType: 'application/json',
read: async ({ supabase, companyId }) => {
const today = new Date().toISOString().slice(0, 10)
+185 -35
View File
@@ -58,6 +58,15 @@ import {
IdempotencyKeyReuseError,
} from '@/lib/api/idempotency'
import { toToolError, type NextActionHint } from './tool-result'
import {
addCompanyToNextHint,
addCompanyToTopLevelNext,
assertMcpCompanyWriteAccess,
extractRequestedCompany,
isCompanyDependentTool,
projectToolInputSchema,
resolveMcpCompanyContext,
} from './company-routing'
import { findSupplierCandidates } from './supplier-candidates'
import { assertNoPlaintextPersonnummer } from './staging-pii-guard'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
@@ -112,6 +121,7 @@ import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { commitPendingOperation } from '@/lib/pending-operations/commit'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { getUserCompanies } from '@/lib/company/context'
// ensureInitialized() is called by the extension router (ext/[...path]/route.ts)
// which dispatches to this handler: no duplicate call needed here.
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem, PendingOperation, VatPeriodType } from '@/types'
@@ -333,7 +343,7 @@ async function stagePendingOperation(
message: `Dry run: would stage "${operationType}" (risk: ${riskLevel}). No changes made.`,
preview: previewData,
...(periodStatus ? { period_status: periodStatus } : {}),
...(next ? { next } : {}),
...(next ? { next: addCompanyToNextHint(next, companyId) as NextActionHint } : {}),
}
}
@@ -358,7 +368,12 @@ async function stagePendingOperation(
? `Replayed cached response for idempotency_key "${options.idempotencyKey}": already staged as pending_operation ${cachedOpId}. No new side-effects. ${buildApprovalGuidance(cachedOpId, riskLevel)}`
: `Replayed cached response for idempotency_key "${options.idempotencyKey}". No new side-effects.`,
...(cachedOpId
? { approve: { tool: 'gnubok_approve_pending_operation', args: { operation_id: cachedOpId } } }
? {
approve: {
tool: 'gnubok_approve_pending_operation',
args: { operation_id: cachedOpId, company_id: companyId },
},
}
: {}),
preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData,
...(periodStatus ? { period_status: periodStatus } : {}),
@@ -399,11 +414,11 @@ async function stagePendingOperation(
message: `Staged as pending_operation ${data.id} (risk: ${riskLevel}). ${buildApprovalGuidance(data.id, riskLevel)} The user can also approve at /pending in the ${branding} web app.`,
approve: {
tool: 'gnubok_approve_pending_operation',
args: { operation_id: data.id } as Record<string, unknown>,
args: { operation_id: data.id, company_id: companyId } as Record<string, unknown>,
},
preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData,
...(periodStatus ? { period_status: periodStatus } : {}),
...(next ? { next } : {}),
...(next ? { next: addCompanyToNextHint(next, companyId) as NextActionHint } : {}),
} as const
if (options.idempotencyKey && requestHash) {
@@ -1760,7 +1775,7 @@ export const tools: McpTool[] = [
name: t.name,
description: t.description,
scope: requiredScope,
inputSchema: t.inputSchema,
inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
@@ -1779,6 +1794,109 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_list_companies',
title: 'List Companies',
description: 'List every non-archived company this API-key user can access. Use company_id from this result on other tools; omit it there to use the API key default.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
companies: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
company_id: { type: 'string' },
name: { type: 'string' },
org_number: { type: ['string', 'null'] },
entity_type: { type: ['string', 'null'] },
role: { type: 'string', enum: ['owner', 'admin', 'member', 'viewer'] },
is_default: { type: 'boolean' },
},
required: ['company_id', 'name', 'org_number', 'entity_type', 'role', 'is_default'],
},
},
count: { type: 'number' },
default_company_id: { type: ['string', 'null'] },
},
required: ['companies', 'count', 'default_company_id'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(_args, defaultCompanyId, userId, supabase) {
type CompanyRow = {
id: string
name: string
org_number: string | null
entity_type: string | null
archived_at: string | null
}
type MembershipRow = {
company_id: string
role: 'owner' | 'admin' | 'member' | 'viewer'
companies: CompanyRow | CompanyRow[] | null
}
const memberships = (await getUserCompanies(supabase, userId)) as unknown as MembershipRow[]
const accessible = memberships.flatMap((membership) => {
const company = Array.isArray(membership.companies)
? membership.companies[0]
: membership.companies
return company && company.archived_at === null ? [{ membership, company }] : []
})
const companyIds = accessible.map(({ company }) => company.id)
const displayNames = new Map<string, string>()
if (companyIds.length > 0) {
try {
const settings = await fetchAllRows<{ company_id: string; company_name: string | null }>(
({ from, to }) =>
supabase
.from('company_settings')
.select('company_id, company_name')
.in('company_id', companyIds)
.order('company_id', { ascending: true })
.range(from, to),
)
for (const row of settings) {
if (row.company_name) displayNames.set(row.company_id, row.company_name)
}
} catch (error) {
log.warn('gnubok_list_companies display-name lookup failed', {
error: error instanceof Error ? error.message : 'unknown',
})
}
}
const companies = accessible.map(({ membership, company }) => ({
company_id: company.id,
name: displayNames.get(company.id) ?? company.name,
org_number: company.org_number,
entity_type: company.entity_type,
role: membership.role,
is_default: company.id === defaultCompanyId,
}))
const hasAccessibleDefault = companies.some((company) => company.is_default)
return {
companies,
count: companies.length,
default_company_id: hasAccessibleDefault ? defaultCompanyId : null,
}
},
},
{
name: 'gnubok_list_skills',
title: 'List Domain Skills',
@@ -2245,10 +2363,10 @@ export const tools: McpTool[] = [
type: 'object',
additionalProperties: false,
description:
'The single company every tool call in this session reads and writes. Confirm this is the entity the user means BEFORE any staged write: there is no per-call company switch; scope is fixed by the API key.',
'The company selected for this call. Confirm it is the entity the user means before staging a write. Pass company_id on later calls to keep working in a non-default company.',
properties: {
id: { type: 'string', description: 'Deprecated: read company_id instead.' },
company_id: { type: 'string', description: 'company_id this session is scoped to.' },
company_id: { type: 'string', description: 'company_id selected for this call.' },
name: { type: ['string', 'null'] },
org_number: { type: ['string', 'null'] },
entity_type: { type: ['string', 'null'], description: 'e.g. "aktiebolag", "enskild_firma". Null if unset.' },
@@ -2493,10 +2611,10 @@ export const tools: McpTool[] = [
.select('full_name')
.eq('id', userId)
.maybeSingle(),
// Company identity so the agent can confirm WHICH entity it operates on
// before any write. Scope is fixed by the API key: there is no per-call
// switch: so this is the session's "whoami for the company". Best-effort:
// a failed read still yields a company block with at least the id.
// Company identity so the agent can confirm which entity it operates on
// before any write. The dispatcher has already resolved and authorized
// the optional per-call company_id. A failed read still yields a company
// block with at least the id.
supabase
.from('companies')
.select('name, org_number, entity_type')
@@ -12006,20 +12124,6 @@ export const tools: McpTool[] = [
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const assetIds = Array.isArray(args.asset_ids) ? (args.asset_ids as string[]) : undefined
// Mirror the HTTP route's `requireWrite: true` guard so a viewer-role
// member can't post depreciation through the MCP surface. RLS would
// reject the underlying INSERTs anyway, but failing fast here
// produces a much cleaner error than the cascaded RLS rejection.
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (!membership || membership.role === 'viewer') {
throw new Error('Write permission required')
}
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, name, period_end, is_closed, locked_at, closing_entry_id')
@@ -12629,7 +12733,7 @@ function emitToolCallTelemetry(payload: {
success: boolean
isError: boolean
errorCode: string | null
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'company_access_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
errorMessage: string | null
requestId: string | number | null
userId: string
@@ -12957,6 +13061,8 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
'',
'Discovery:',
'• tools/list returns the full schema for every tool. To narrow a large catalog, call gnubok_search_tools(query="…"): it ranks tools by relevance; pass detail="name"|"summary"|"full" to control payload size.',
`• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId}); when selecting another company, repeat company_id on every company-data call, including approval.`,
'• MCP resources use the API key default company. For a selected non-default company, call gnubok_get_agent_briefing with company_id instead of relying on Accounted://company/current or other company-data resources.',
'• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first: domain workflows are documented as loadable skills with tool references.',
'',
'Common workflows:',
@@ -13014,7 +13120,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
name: t.name,
...(t.title ? { title: t.title } : {}),
description: t.description,
inputSchema: t.inputSchema,
inputSchema: projectToolInputSchema(t),
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
@@ -13026,7 +13132,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
case 'tools/call': {
const toolName = (params as Record<string, unknown>)?.name as string
const toolArgs = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<
const rawToolArgs = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<
string,
unknown
>
@@ -13085,12 +13191,55 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
)
}
let toolArgs: Record<string, unknown>
let effectiveCompanyId = companyId
const companyRoutingStartedAt = Date.now()
try {
const extracted = extractRequestedCompany(rawToolArgs)
toolArgs = extracted.toolArgs
if (isCompanyDependentTool(toolName)) {
const companyContext = await resolveMcpCompanyContext({
supabase,
userId,
defaultCompanyId: companyId,
requestedCompanyId: extracted.requestedCompanyId,
})
assertMcpCompanyWriteAccess(companyContext, requiredScope)
effectiveCompanyId = companyContext.companyId
}
} catch (err) {
const structured = toToolError(err, { toolName })
emitToolCallTelemetry({
tool: toolName,
requiredScope: requiredScope ?? null,
actor,
latencyMs: Date.now() - companyRoutingStartedAt,
success: false,
isError: true,
errorCode: structured.error.code,
errorKind: 'company_access_denied',
errorMessage: structured.error.message_sv,
requestId: id ?? null,
userId,
// Keep denied attempts attributed to the key default. An arbitrary,
// unauthorized target must never create tenant telemetry there.
companyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
isError: true,
})
)
}
// Enforce the capability paywall: the MCP/agent path is a paid chokepoint
// just like the HTTP routes (send_invoice → email_send, the two SKV
// submissions → skatteverket). Fail-closed; self-hosted short-circuits to
// all-on inside hasCapability. Blocks before any pending op is staged.
const requiredCapability = MCP_TOOL_CAPABILITY_MAP[toolName]
if (requiredCapability && !(await hasCapability(supabase, companyId, requiredCapability))) {
if (requiredCapability && !(await hasCapability(supabase, effectiveCompanyId, requiredCapability))) {
const capError = { error: capabilityBlockedError(requiredCapability) }
emitToolCallTelemetry({
tool: toolName,
@@ -13104,7 +13253,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
errorMessage: capError.error.message_sv,
requestId: id ?? null,
userId,
companyId,
companyId: effectiveCompanyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
@@ -13144,7 +13293,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
errorMessage: blocked.error.message_sv,
requestId: id ?? null,
userId,
companyId,
companyId: effectiveCompanyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
@@ -13158,7 +13307,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
// Detect if THIS call follows the previous call's `next` hint: must
// run before execute() so we don't double-store on this call. Emits
// mcp.next_hint_followed when the agent's behaviour matches the hint.
checkAndEmitNextHintFollowed(sessionId, toolName, actor, userId, companyId)
checkAndEmitNextHintFollowed(sessionId, toolName, actor, userId, effectiveCompanyId)
const callStartedAt = Date.now()
try {
@@ -13167,7 +13316,8 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
if (toolName === 'gnubok_search_tools') {
(toolArgs as Record<string, unknown>).__keyScopes = keyScopes
}
const result = await tool.execute(toolArgs, companyId, userId, supabase, actor)
const rawResult = await tool.execute(toolArgs, effectiveCompanyId, userId, supabase, actor)
const result = addCompanyToTopLevelNext(rawResult, effectiveCompanyId)
const latencyMs = Date.now() - callStartedAt
const response: Record<string, unknown> = {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -13208,7 +13358,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
errorMessage: null,
requestId: id ?? null,
userId,
companyId,
companyId: effectiveCompanyId,
})
return NextResponse.json(jsonRpc(id ?? null, response))
} catch (err) {
@@ -13229,7 +13379,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
errorMessage: structured.error.message_sv,
requestId: id ?? null,
userId,
companyId,
companyId: effectiveCompanyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {